Skip to content
This repository was archived by the owner on Oct 27, 2022. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,36 @@ Run Serverless Sample Tester by passing in the root directory of the sample you
./sst [target-dir]
```


## Build and Deploying Your Sample
This program allows you to specify how you would like it to build and deploy your sample to Cloud Run, fully managed,
in three ways. From highest to lowest, this program uses the following precedence order to find your build and deploy
instructions:

1. Cloud Build Config file
1. README parsing
1. Defaults

### Cloud Build Config
If you'd like, you can specify the build and deploy process for your sample in a [Cloud Build config file](https://cloud.google.com/cloud-build/docs/build-config)
with the name `cloudbuild.yaml` located in the root directory of your sample. Make sure to deploy to the Cloud Run fully
managed platform.

You can specify the substitutions you'd like passed to your Cloud Build config by either setting the
`SST_CLOUD_BUILD_SUBS` environment variable in a JSON format or the `--cloud-build-subs` flag in a comma-separated
format when calling the `sst` executable. For example:

```bash
export SST_CLOUD_BUILD_SUBS="{\"_FOO\": \"hello\",\"_BAR\": \"world\"}"
sst [sample-dir] # or

sst [sample-dir] --cloud-build-subs="_FOO=hello,_BAR=world"
```

It's required that you use the `_SST_RUN_REGION` [substitution](https://cloud.google.com/cloud-build/docs/configuring-builds/substitute-variable-values)
for the Cloud Run region you deploy your sample to. The substitution will be set as the same region specified by your
local gcloud installation's `run/region` property.

### README parsing
To parse build and deploy commands from your sample's README, include the following comment code tag before each gcloud command:

Expand Down Expand Up @@ -79,3 +109,12 @@ what the name is, but it may not always be accurate. For example, if your README
gcloud run deploy run-mysql --image gcr.io/[YOUR_PROJECT_ID]/run-mysql
```
then `$CLOUD_RUN_SERVICE_NAME` should be set to `run-mysql`.

### Defaults
If your sample is Java-based (has a `pom.xml` file in its root directory) and has a `Dockerfile` in its root directory,
your sample will be built and pushed the Container Registry using `mvn compile com.google.cloud.tools:jib-maven-plugin:2.0.0:build -Dimage=[image_tag]`.

Otherwise, your sample will be built and pushed to the Container Registry using `gcloud builds submit --tag=[image_tag]`.

In both cases, `gcloud run deploy --image=[image_tag] --platform=managed`, will be used to deploy the container image to
Cloud Run.
25 changes: 17 additions & 8 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,31 @@ import (

var (
rootCmd = &cobra.Command{
Use: "sst [sample-dir]",
Short: "An end-to-end tester for GCP samples",
Args: cobra.ExactArgs(1),
Use: "sst [sample-dir]",
Short: "An end-to-end tester for GCP samples",
Args: cobra.ExactArgs(1),
SilenceErrors: true,
SilenceUsage: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
// Parse sample directory from command line argument
sampleDir, err := filepath.Abs(filepath.Dir(args[0]))
if err != nil {
return err
}

log.Println("Setting up configuration values")
// Set up config file location
log.Println("Setting up configuration values")
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(sampleDir)
s, err := sample.NewSample(sampleDir)

s, cleanup, err := sample.NewSample(sampleDir, viper.GetStringMapString("cloud_build_subs"))
if err != nil {
return err
}
if cleanup != nil {
defer cleanup()
}

log.Println("Loading test endpoints")
swagger := util.LoadTestEndpoints()
Expand Down Expand Up @@ -93,7 +97,12 @@ func Execute() error {
return rootCmd.Execute()
}

// init initializes the tool.
// init binds command line flags and environment variables to viper configuration keys.
func init() {
// Initialization goes here
viper.SetEnvPrefix("sst")

viper.SetDefault("cloud_build_subs", map[string]string{})
viper.BindEnv("cloud_build_subs")
rootCmd.PersistentFlags().StringToString("cloud-build-subs", map[string]string{}, "")
viper.BindPFlag("cloud_build_subs", rootCmd.PersistentFlags().Lookup("cloud-build-subs"))
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ require (
github.com/getkin/kin-openapi v0.18.0
github.com/spf13/cobra v1.0.0
github.com/spf13/viper v1.7.1
gopkg.in/yaml.v2 v2.3.0
)
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
Expand Down Expand Up @@ -71,6 +72,7 @@ github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OI
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
Expand Down Expand Up @@ -103,6 +105,7 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
Expand Down Expand Up @@ -159,7 +162,9 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
Expand Down
122 changes: 122 additions & 0 deletions internal/lifecycle/cloud_build_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2020 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 lifecycle

import (
"fmt"
"github.com/GoogleCloudPlatform/serverless-sample-tester/internal/util"
"gopkg.in/yaml.v2"
"io/ioutil"
"log"
"os"
"os/exec"
"strings"
)

// runRegionSubstitution is the substitution used to specify which Cloud Run region Cloud Build configs will deploy
// samples to.
const runRegionSubstitution = "_SST_RUN_REGION"

// getCloudBuildConfigLifecycle returns a Lifecycle for the executing the provided Cloud Build config file. It creates
// and uses a temporary copy of the file where it replaces the Cloud Run service names and Container Registry tags with
// the provided inputs. It provides also passes in the provided substitutions as well a runRegionSubstitution with the
// provided region. Also returns a function that removes the temp file created while making Lifecycle. This function
// should be called after Lifecycle is done executing.
func getCloudBuildConfigLifecycle(filename, serviceName, gcrURL, runRegion string, substitutions map[string]string) (Lifecycle, func(), error) {
config := make(map[string]interface{})

buildConfigBytes, err := ioutil.ReadFile(filename)
if err != nil {
return nil, nil, fmt.Errorf("ioutil.ReadFile: %w", err)
}

err = yaml.Unmarshal(buildConfigBytes, &config)
if err != nil {
return nil, nil, fmt.Errorf("yaml.Unmarshal: %s: %w", filename, err)
}

// Replace Cloud Run service names and Container Registry URLs
for stepIndex := range config["steps"].([]interface{}) {
var args []string
for argIndex := range config["steps"].([]interface{})[stepIndex].(map[interface{}]interface{})["args"].([]interface{}) {
arg := config["steps"].([]interface{})[stepIndex].(map[interface{}]interface{})["args"].([]interface{})[argIndex].(string)
arg = gcrURLRegexp.ReplaceAllString(arg, gcrURL)

args = append(args, arg)
}

prog := config["steps"].([]interface{})[stepIndex].(map[interface{}]interface{})["name"].(string)
err := replaceServiceName(prog, args, serviceName)
if err != nil {
return nil, nil, fmt.Errorf("replacing Cloud Run service name in %s step args: %w", filename, err)
}

config["steps"].([]interface{})[stepIndex].(map[interface{}]interface{})["args"] = args
}

configMarshalBytes, err := yaml.Marshal(&config)
if err != nil {
return nil, nil, fmt.Errorf("yaml.Marshal: modified %s: %w", filename, err)
}

tempBuildConfigFile, err := ioutil.TempFile("", "cloudbuild.*.yaml")
if err != nil {
return nil, nil, fmt.Errorf("ioutil.TempFile: %w\n", err)
}
cleanup := func() {
err := os.Remove(tempBuildConfigFile.Name())
if err != nil {
log.Printf("Error removing Temp File for Cloud Build config: %v\n", err)
}
}

if _, err := tempBuildConfigFile.Write(configMarshalBytes); err != nil {
cleanup()
return nil, nil, fmt.Errorf("os.File.Write: TempFile %s: %w", tempBuildConfigFile.Name(), err)
}
if err := tempBuildConfigFile.Close(); err != nil {
cleanup()
return nil, nil, fmt.Errorf("os.File.Close: TempFile %s: %w", tempBuildConfigFile.Name(), err)
}

return buildCloudBuildConfigLifecycle(tempBuildConfigFile.Name(), runRegion, substitutions), cleanup, nil
}

// buildCloudBuildConfigLifecycle returns a Lifecycle with a single command that calls gcloud builds subit and passes
// in the provided Cloud Build config file. It also adds a `--substitutions` flag according to the substitutions
// provided and adds a substitution for the Cloud Run region with the name runRegionSubstitution and value provided.
func buildCloudBuildConfigLifecycle(buildConfigFilename, runRegion string, substitutions map[string]string) Lifecycle {
a := append(util.GcloudCommonFlags, "builds", "submit",
fmt.Sprintf("--config=%s", buildConfigFilename))

subsitutions := substitutionsString(substitutions, runRegion)
a = append(a, fmt.Sprintf("--substitutions=%s", subsitutions))

return Lifecycle{exec.Command("gcloud", a...)}
}

// substitutionsString takes a string to string map and converts it into an argument for the `gcloud builds submit`
// `--config` file. It treats the keys in the map as the substitutions and the values as the substitution values. It
// also adds a substitution for the Cloud Run region with the name runRegionSubstitution and value provided.
func substitutionsString(m map[string]string, runRegion string) string {
var subs []string
subs = append(subs, fmt.Sprintf("%s=%s", runRegionSubstitution, runRegion))

for k, v := range m {
subs = append(subs, fmt.Sprintf("%s=%s", k, v))
}

return strings.Join(subs, ",")
}
79 changes: 73 additions & 6 deletions internal/lifecycle/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)

var gcrURLRegexp = regexp.MustCompile(`gcr.io/.+/\S+`)

// Lifecycle is a list of ordered exec.Cmd that should be run to execute a certain process.
type Lifecycle []*exec.Cmd

Expand All @@ -46,8 +50,24 @@ func (l Lifecycle) Execute(commandsDir string) error {

// NewLifecycle tries to parse the different options provided for build and deploy command configuration. If none of
// those options are set up, it falls back to reasonable defaults based on whether the sample is java-based
// (has a pom.xml) that doesn't have a Dockerfile or isn't.
func NewLifecycle(sampleDir, serviceName, gcrURL string) (Lifecycle, error) {
// (has a pom.xml) that doesn't have a Dockerfile or isn't. Also returns a function that cleans up any created local
// resources (e.g. temp files) created while making creating this Lifecycle. This function should be called after this
// Lifecycle is done executing.
func NewLifecycle(sampleDir, serviceName, gcrURL, runRegion string, cloudBuildConfSubs map[string]string) (Lifecycle, func(), error) {
// First try Cloud Build Config file
cloudBuildConfigPath := fmt.Sprintf("%s/cloudbuild.yaml", sampleDir)

if _, err := os.Stat(cloudBuildConfigPath); err == nil {
lifecycle, cleanup, err := getCloudBuildConfigLifecycle(cloudBuildConfigPath, serviceName, gcrURL, runRegion, cloudBuildConfSubs)
if err == nil {
log.Println("Using cloud build config file")
return lifecycle, cleanup, nil
}

return nil, nil, fmt.Errorf("lifecycle.getCloudBuildConfigLifecycle: %s: %w\n", cloudBuildConfigPath, err)
}

// Then try README parsing
var readmePath string
// Searching for config file
if err := viper.ReadInConfig(); err == nil {
Expand All @@ -64,18 +84,19 @@ func NewLifecycle(sampleDir, serviceName, gcrURL string) (Lifecycle, error) {
log.Println("README.md location: " + readmePath)
if err == nil {
log.Println("Using build and deploy commands found in README.md")
return lifecycle, nil
return lifecycle, nil, nil
}

if !errors.Is(err, errNoReadmeCodeBlocksFound) {
return nil, fmt.Errorf("lifecycle.parseREADME: %s: %w", readmePath, err)
return nil, nil, fmt.Errorf("lifecycle.parseREADME: %s: %w", readmePath, err)
}

log.Printf("No code blocks immediately preceded by %s found in README.md\n", codeTag)
} else {
log.Println("No README.md found")
}

// Finally fall back to reasonable defaults
pomPath := filepath.Join(sampleDir, "pom.xml")
dockerfilePath := filepath.Join(sampleDir, "Dockerfile")

Expand All @@ -87,11 +108,11 @@ func NewLifecycle(sampleDir, serviceName, gcrURL string) (Lifecycle, error) {

if pomE && !dockerfileE {
log.Println("Using default build and deploy commands for java samples without a Dockerfile")
return buildDefaultJavaLifecycle(serviceName, gcrURL), nil
return buildDefaultJavaLifecycle(serviceName, gcrURL), nil, nil
}

log.Println("Using default build and deploy commands for non-java samples or java samples with a Dockerfile")
return buildDefaultLifecycle(serviceName, gcrURL), nil
return buildDefaultLifecycle(serviceName, gcrURL), nil, nil
}

// buildDefaultLifecycle builds a build and deploy command lifecycle with reasonable defaults for a non-Java
Expand Down Expand Up @@ -122,3 +143,49 @@ func buildDefaultJavaLifecycle(serviceName, gcrURL string) Lifecycle {

return l
}

// replaceServiceName takes a terminal command string as input and replaces the Cloud Run service name, if any.
// If the user specified the service name in $CLOUD_RUN_SERVICE_NAME, it replaces that. Otherwise, as a failsafe,
// it detects whether the command is a gcloud run command and replaces the last argument that isn't a flag
// with the input service name.
func replaceServiceName(name string, args []string, serviceName string) error {
if !strings.Contains(name, "gcloud") {
return nil
}

var runCmd bool

// Detects if the user specified the Cloud Run service name in an environment variable
for i := 0; i < len(args); i++ {
if args[i] == os.ExpandEnv("$CLOUD_RUN_SERVICE_NAME") {
args[i] = serviceName
return nil
}

if args[i] == "run" {
runCmd = true
break
}
}

if !runCmd {
return nil
}

// Searches for specific gcloud keywords and takes service name from them
for i := 0; i < len(args)-1; i++ {
if args[i] == "deploy" || args[i] == "update" {
args[i+1] = serviceName
return nil
}
}

// Provides a failsafe if neither of the above options work
for i := len(args) - 1; i >= 0; i-- {
if !strings.Contains(args[i], "--") {
args[i] = serviceName
break
}
}
return nil
}
Loading