Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a Cloud Function example #36

Merged
merged 1 commit into from Apr 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 16 additions & 2 deletions examples/examples_nodejs_test.go
Expand Up @@ -4,15 +4,29 @@
package examples

import (
"github.com/pulumi/pulumi/pkg/v3/testing/integration"
"path/filepath"
"testing"
"github.com/pulumi/pulumi/pkg/v3/testing/integration"
)

func TestCloudRunTs(t *testing.T) {
test := getJSBaseOptions(t).
With(integration.ProgramTestOptions{
Dir: filepath.Join(getCwd(t), "cloudrun-ts"),
Dir: filepath.Join(getCwd(t), "cloudrun-ts"),
SkipRefresh: true,
})

integration.ProgramTest(t, &test)
}

func TestFunctionsTs(t *testing.T) {
test := getJSBaseOptions(t).
With(integration.ProgramTestOptions{
Dir: filepath.Join(getCwd(t), "functions-ts"),
ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) {
assertHTTPHelloWorld(t, stack.Outputs["functionUrl"].(string), nil)
},
SkipRefresh: true,
})

integration.ProgramTest(t, &test)
Expand Down
91 changes: 91 additions & 0 deletions examples/examples_test.go
Expand Up @@ -3,8 +3,14 @@
package examples

import (
"fmt"
"github.com/stretchr/testify/assert"
"io/ioutil"
"net/http"
"os"
"strings"
"testing"
"time"

"github.com/pulumi/pulumi/pkg/v3/testing/integration"
)
Expand Down Expand Up @@ -63,3 +69,88 @@ func skipIfShort(t *testing.T) {
t.Skip("skipping long-running test in short mode")
}
}

func assertHTTPResult(t *testing.T, output interface{}, headers map[string]string, check func(string) bool) bool {
return assertHTTPResultWithRetry(t, output, headers, 5*time.Minute, check)
}

func assertHTTPResultWithRetry(t *testing.T, output interface{}, headers map[string]string, maxWait time.Duration, check func(string) bool) bool {
return assertHTTPResultShapeWithRetry(t, output, headers, maxWait, func(string) bool { return true }, check)
}

func assertHTTPResultShapeWithRetry(t *testing.T, output interface{}, headers map[string]string, maxWait time.Duration,
ready func(string) bool, check func(string) bool) bool {
hostname, ok := output.(string)
if !assert.True(t, ok, fmt.Sprintf("expected `%s` output", output)) {
return false
}

if !(strings.HasPrefix(hostname, "http://") || strings.HasPrefix(hostname, "https://")) {
hostname = fmt.Sprintf("http://%s", hostname)
}

startTime := time.Now()
count, sleep := 0, 0
for true {
now := time.Now()
req, err := http.NewRequest("GET", hostname, nil)
if !assert.NoError(t, err) {
return false
}

for k, v := range headers {
// Host header cannot be set via req.Header.Set(), and must be set
// directly.
if strings.ToLower(k) == "host" {
req.Host = v
continue
}
req.Header.Set(k, v)
}

client := &http.Client{Timeout: time.Second * 10}
resp, err := client.Do(req)
if err == nil && resp.StatusCode == 200 {
if !assert.NotNil(t, resp.Body, "resp.body was nil") {
return false
}

// Read the body
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if !assert.NoError(t, err) {
return false
}

bodyText := string(body)

// Even if we got 200 and a response, it may not be ready for assertion yet - that's specific per test.
if ready(bodyText) {
// Verify it matches expectations
return check(bodyText)
}
}
if now.Sub(startTime) >= maxWait {
fmt.Printf("Timeout after %v. Unable to http.get %v successfully.", maxWait, hostname)
return false
}
count++
// delay 10s, 20s, then 30s and stay at 30s
if sleep > 30 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just replace with "github.com/jpillora/backoff" as we do in the provider?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied this 1:1 from the examples repo. We could improve both I guess.

sleep = 30
} else {
sleep += 10
}
time.Sleep(time.Duration(sleep) * time.Second)
fmt.Printf("Http Error: %v\n", err)
fmt.Printf(" Retry: %v, elapsed wait: %v, max wait %v\n", count, now.Sub(startTime), maxWait)
}

return false
}

func assertHTTPHelloWorld(t *testing.T, output interface{}, headers map[string]string) bool {
return assertHTTPResult(t, output, headers, func(s string) bool {
return assert.Equal(t, "Hello, World!", s)
})
}
3 changes: 3 additions & 0 deletions examples/functions-ts/Pulumi.yaml
@@ -0,0 +1,3 @@
name: gcp-native-functions-ts
runtime: nodejs
description: A simple example of Google Cloud Functions
62 changes: 62 additions & 0 deletions examples/functions-ts/index.ts
@@ -0,0 +1,62 @@
// Copyright 2016-2021, Pulumi Corporation.

import * as pulumi from "@pulumi/pulumi";
import * as gcp from "@pulumi/gcp-native";
import * as random from "@pulumi/random"

const config = new pulumi.Config("gcp-native");
const project = config.require("project");
const region = config.require("region");

const randomString = new random.RandomString("name", {
upper: false,
number: false,
special: false,
length: 8,
});

const bucketName = pulumi.interpolate`bucket-${randomString.result}`;
const bucket = new gcp.storage.v1.Bucket("bucket", {
project: project,
bucket: bucketName,
name: bucketName,
});

const archiveName = "zip";
const bucketObject = new gcp.storage.v1.BucketObject(archiveName, {
object: archiveName,
name: archiveName,
bucket: bucket.name,
source: new pulumi.asset.AssetArchive({
".": new pulumi.asset.FileArchive("./pythonfunc"),
}),
});

const functionName = pulumi.interpolate`func-${randomString.result}`;
const func = new gcp.cloudfunctions.v1.Function("function-py", {
projectsId: project,
locationsId: region,
functionsId: functionName,
name: pulumi.interpolate`projects/${project}/locations/${region}/functions/${functionName}`,
sourceArchiveUrl: pulumi.interpolate`gs://${bucket.name}/${bucketObject.name}`,
httpsTrigger: {},
entryPoint: "handler",
timeout: "60s",
availableMemoryMb: 128,
runtime: "python37",
ingressSettings: "ALLOW_ALL",
});

const invoker = new gcp.cloudfunctions.v1.FunctionIamPolicy("function-py-iam", {
projectsId: project,
locationsId: region,
functionsId: functionName, // func.name returns the long `projects/foo/locations/bat/functions/buzz` name which doesn't suit here
bindings: [
{
members: ["allUsers"],
role: "roles/cloudfunctions.invoker",
}
],
}, { dependsOn: func});

export const functionUrl = func.httpsTrigger.url;
11 changes: 11 additions & 0 deletions examples/functions-ts/package.json
@@ -0,0 +1,11 @@
{
"name": "functions-ts",
"version": "0.1.0",
"devDependencies": {
"@types/node": "latest"
},
"dependencies": {
"@pulumi/pulumi": "^3.0.0-alpha.0",
"@pulumi/random": "^4.0.0-alpha.0"
}
}
6 changes: 6 additions & 0 deletions examples/functions-ts/pythonfunc/main.py
@@ -0,0 +1,6 @@
def handler(request):
headers = {
'Content-Type': 'text/plain'
}

return ('Hello, World!', 200, headers)
18 changes: 18 additions & 0 deletions examples/functions-ts/tsconfig.json
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"outDir": "bin",
"target": "es2016",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"files": [
"index.ts"
]
}
12 changes: 6 additions & 6 deletions provider/pkg/provider/provider.go
Expand Up @@ -256,13 +256,14 @@ func (k *googleCloudProvider) Create(ctx context.Context, req *rpc.CreateRequest
"selfLink": obj.SelfLink,
}
default:
uri = fmt.Sprintf("%s%s", res.BaseUrl, res.CreatePath)
path := res.CreatePath
for _, param := range res.CreateParams {
key := resource.PropertyKey(param)
value := inputs[key].StringValue()
uri = strings.Replace(uri, fmt.Sprintf("{%s}", param), value, 1)
path = strings.Replace(path, fmt.Sprintf("{%s}", param), value, 1)
delete(inputs, key)
}
uri = res.RelativePath(path)
for _, param := range res.IdParams {
key := resource.PropertyKey(param)
delete(inputs, key)
Expand Down Expand Up @@ -393,7 +394,7 @@ func (k *googleCloudProvider) Read(ctx context.Context, req *rpc.ReadRequest) (*
return &rpc.ReadResponse{Id: id}, nil
}

uri := fmt.Sprintf("%s%s", res.BaseUrl, id)
uri := res.RelativePath(id)

// Retrieve the old state.
oldState, err := plugin.UnmarshalProperties(req.GetProperties(), plugin.MarshalOptions{
Expand Down Expand Up @@ -462,7 +463,7 @@ func (k *googleCloudProvider) Update(ctx context.Context, req *rpc.UpdateRequest
parent[name] = inputsMap[name]
}

uri := res.BaseUrl + req.Id
uri := res.RelativePath(req.GetId())
op, err := sendRequestWithTimeout(ctx, res.UpdateVerb, uri, body, 0)
if err != nil {
return nil, fmt.Errorf("error sending request: %s: %q %+v", err, uri, body)
Expand Down Expand Up @@ -509,8 +510,7 @@ func (k *googleCloudProvider) Delete(ctx context.Context, req *rpc.DeleteRequest
return &empty.Empty{}, nil
}

id := req.GetId()
uri := fmt.Sprintf("%s/%s", res.BaseUrl, id)
uri := res.RelativePath(req.GetId())

resp, err := sendRequestWithTimeout(ctx, "DELETE", uri, nil, 0)
if err != nil {
Expand Down
11 changes: 11 additions & 0 deletions provider/pkg/resources/resources.go
Expand Up @@ -2,6 +2,11 @@

package resources

import (
"fmt"
"strings"
)

// CloudAPIMetadata is a collection of all resources and functions in the Google Cloud REST API.
type CloudAPIMetadata struct {
Resources map[string]CloudAPIResource `json:"resources"`
Expand All @@ -20,6 +25,12 @@ type CloudAPIResource struct {
NoGet bool `json:"noGet,omitempty"`
NoDelete bool `json:"noDelete,omitempty"`
}

// RelativePath joins the resource base URL with the given path.
func (r *CloudAPIResource) RelativePath(rel string) string {
return fmt.Sprintf("%s/%s", strings.TrimRight(r.BaseUrl, "/"), strings.TrimLeft(rel, "/"))
}

// CloudAPIProperty is a property of a body of an API call payload.
type CloudAPIProperty struct {
// The name of the container property that was "flattened" during SDK generation, i.e. extra layer that exists
Expand Down