Skip to content
This repository has been archived by the owner on Jun 11, 2019. It is now read-only.

Initial sketch for subscribing to azure functions off of triggers. #1

Merged
merged 20 commits into from
Aug 3, 2018
156 changes: 156 additions & 0 deletions nodejs/azure-serverless/blob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright 2016-2018, Pulumi Corporation.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Start with subscription.ts first.

//
// 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.

import * as azure from "@pulumi/azure";
import * as pulumi from "@pulumi/pulumi";

import * as azurefunctions from "azure-functions-ts-essentials";
import * as azurestorage from "azure-storage";

import * as subscription from "./subscription";

interface BlobBinding extends subscription.Binding {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

note: this is an internal type. so it exists mostly just to document certain properties and to make it clear that the values we're passing are intentional.

/**
* The name of the property in the context object to bind the actual blob value to.
* Note really important in our implementation as the blob value will be passed as
* the second argument to the callback function.
*/
name: string;

/**
* The type of a blob binding. Must be 'blobTrigger'.
*/
type: "blobTrigger";

/**
* The direction of the binding. We only 'support' blobs being inputs to functions.
*/
direction: "in";

/**
* How we want the blob represented when passed into the callback. We specify 'binary'
* so that all data is passed in as a buffer. Otherwise, Azure will attempt to sniff
* the content and convert it accordingly. This gives us a consistent way to know what
* data will be passed into the function.
*/
dataType: "binary";

/**
* The path to the blob we want to create a trigger for.
*/
path: string;

/**
* The storage connection string for the storage account containing the blob.
*/
connection: string;
}

/**
* Data that will be passed along in the context object to the BlobCallback.
*/
export interface BlobContext extends azurefunctions.Context {
"executionContext": {
"invocationId": string;
"functionName": string;
"functionDirectory": string;
};

"bindingData": {
"blobTrigger": string;
"uri": string;
"properties": {
"cacheControl": any,
"contentDisposition": any,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

things that are 'any' in this list basically got passed to me as 'null', so i didn't know how to determine what the type actually is easily. effectively, i'm lazy and didn't want to go hunt these down (yet).

"contentEncoding": any,
"contentLanguage": any,
"length": number,
"contentMD5": any,
"contentType": string,
"eTag": string,
"lastModified": string,
"blobType": string,
"leaseStatus": string,
"leaseState": string,
"leaseDuration": string,
"pageBlobSequenceNumber": any,
"appendBlobCommittedBlockCount": any,
"isServerEncrypted": boolean,
},
"metadata": Record<string, string>,
"sys": {
"methodName": string,
"utcNow": string,
},
"invocationId": string,
};
}

/**
* Signature of the callback that can receive blob notifications.
*/
export type BlobCallback = subscription.Callback<BlobContext, Buffer>;

/**
* Creates a new subscription to the given blob using the callback provided, along with optional
* options to control the behavior of the subscription.
*/
export async function onEvent(
name: string, blob: azure.storage.Blob, callback: BlobCallback,
args: subscription.EventSubscriptionArgs, opts?: pulumi.ResourceOptions): Promise<BlobEventSubscription> {

// First, get the storage connection string for the storage account this blob is associated
// with.
//
// TODO(cyrusn): This could be expensive (especially if many blobs are subscribed to). Should
// we consider providing some mechanism for the caller to pass this in?
//
// Also, in general, Azure puts these connection strings into an AppSetting, and just looks
// things up that way. We could consider doing the same.
const accountResult = await azure.storage.getAccount({
name: blob.storageAccountName,
resourceGroupName: blob.resourceGroupName,
});

const bindingOutput =
pulumi.all([blob.storageContainerName, blob.name, accountResult.primaryConnectionString])
.apply(([containerName, blobName, connectionString]) => {
const binding: BlobBinding = {
name: "blob",
type: "blobTrigger",
direction: "in",
dataType: "binary",
path: containerName + "/" + blobName,
connection: connectionString,
};

return binding;
});

return new BlobEventSubscription(name, blob, callback, bindingOutput, args, opts);
}

export class BlobEventSubscription extends subscription.EventSubscription<BlobContext, Buffer> {
readonly blob: azure.storage.Blob;

constructor(
name: string, blob: azure.storage.Blob, callback: BlobCallback, binding: pulumi.Output<BlobBinding>,
args?: subscription.EventSubscriptionArgs, options?: pulumi.ResourceOptions) {

super("azure-serverless:blob:BlobEventSubscription", name, callback,
binding.apply(b => [b]), args, options);

this.blob = blob;
}
}
207 changes: 207 additions & 0 deletions nodejs/azure-serverless/subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Copyright 2016-2018, Pulumi Corporation.
//
// 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.

import * as azure from "@pulumi/azure";
import * as pulumi from "@pulumi/pulumi";

import * as azurefunctions from "azure-functions-ts-essentials";
import * as azurestorage from "azure-storage";

const config = new pulumi.Config("azure");
const region = config.require("region");
Copy link
Contributor Author

Choose a reason for hiding this comment

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

opinionated way to specify the region.


type Context = azurefunctions.Context;

/**
* A synchronous function that can be converted into an Azure FunctionApp. This callback should
* return nothing, and should signal that it is done by calling `context.Done()`. Errors can be
* signified by calling `context.Done(err)`
*/
export type Callback<C extends Context, Data> = (context: C, data: Data) => void;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unfortunately:

  1. this can't have a Promise return type afaict (unless azure has added support for that). We could consider providing separate entrypoints to allow someone to pass an async callback. We'd then ".then" that, calling context.Done(...).
  2. As we talked about, this can't be a callback or lambda. We can't make a trigger with a preexisting function. So we only support the callback form here.


export interface EventSubscriptionArgs {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We talkd today about being opinionated here. But it turned out to just be trivial to let the caller supply these sorts of values if they did want to control the FunctionApp they were creating. I figured it was reaosnable to just have this. As you can see, it's all optional. So there is an easy path to make FunctionApps. But you can provide these values if you want.

/**
* The resource group to create the serverless FunctionApp within. If not provided, a new
* resource group will be created with the same name as the pulumi resource. It will be created
* in the region specified by the config variable "azure:region"
*/
resourceGroup?: azure.core.ResourceGroup;

/**
* The storage account to use where the zip-file blob for the FunctionApp will be located. If
* not provided, a new storage account will create. It will be a 'Standard', 'LRS', 'StorageV2'
* account.
*/
storageAccount?: azure.storage.Account;

/**
* The container to use where the zip-file blob for the FunctionApp will be located. If not
* provided, the root container of the storage account will be used.
*/
storageContainer?: azure.storage.Container;

/**
* The consumption plan to put the FunctionApp in. If not provided, a 'Dynamic', 'Y1' plan will
* be used. See https://social.msdn.microsoft.com/Forums/azure/en-US/665c365d-2b86-4a77-8cea-72ccffef216c for
* additional details.
*/
appServicePlan?: azure.appservice.Plan;
}

/**
* Represents a Binding that will be emitted into the function.json config file for the FunctionApp.
* Individual services will have more specific information they will define in their own bindings.
*/
export interface Binding {
type: string;
direction: string;
name: string;
}

/**
* Takes in a callback and a set of bindings, and produces the right AssetMap layout that Azure
* FunctionApps expect.
*/
function serializeCallbackAndCreateAssetMapOutput<C extends Context, Data>(
name: string, handler: Callback<C, Data>, bindingsOutput: pulumi.Output<Binding[]>): pulumi.Output<pulumi.asset.AssetMap> {

const serializedHandlerOutput = pulumi.output(pulumi.runtime.serializeFunction(handler));
return pulumi.all([bindingsOutput, serializedHandlerOutput]).apply(
([bindings, serializedHandler]) => {

const map: pulumi.asset.AssetMap = {};

map["host.json"] = new pulumi.asset.StringAsset(JSON.stringify({}));
map[`${name}/function.json`] = new pulumi.asset.StringAsset(JSON.stringify({
"disabled": false,
"bindings": bindings,
}));
map[`${name}/index.js`] = new pulumi.asset.StringAsset(`module.exports = require("./handler").handler`),
map[`${name}/handler.js`] = new pulumi.asset.StringAsset(serializedHandler.text);

return map;
});
}

function signedBlobReadUrl(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copied directly from the example. I assume this works...

blob: azure.storage.Blob | azure.storage.ZipBlob,
account: azure.storage.Account,
container: azure.storage.Container,
): pulumi.Output<string> {
// Choose a fixed, far-future expiration date for signed blob URLs.
// The shared access signature (SAS) we generate for the Azure storage blob must remain valid for as long as the
// Function App is deployed, since new instances will download the code on startup. By using a fixed date, rather
// than (e.g.) "today plus ten years", the signing operation is idempotent.
const signatureExpiration = new Date(2100, 1);

return pulumi.all([
account.primaryConnectionString,
container.name,
blob.name,
]).apply(([connectionString, containerName, blobName]) => {
const blobService = new azurestorage.BlobService(connectionString);
const signature = blobService.generateSharedAccessSignature(
containerName,
blobName,
{
AccessPolicy: {
Expiry: signatureExpiration,
Permissions: azurestorage.BlobUtilities.SharedAccessPermissions.READ,
},
},
);

return blobService.getUrl(containerName, blobName, signature);
});
}

/**
* Base type for all subscription types.
*/
export class EventSubscription<C extends Context, Data> extends pulumi.ComponentResource {
readonly resourceGroup: azure.core.ResourceGroup;

readonly storageAccount: azure.storage.Account;
readonly storageContainer: azure.storage.Container;

readonly appServicePlan: azure.appservice.Plan;
readonly functionApp: azure.appservice.FunctionApp;

constructor(type: string, name: string, callback: Callback<C, D>, bindings: pulumi.Output<Binding[]>,
args?: EventSubscriptionArgs, options?: pulumi.ResourceOptions) {
super(type, name, {}, options);

const parentArgs = { parent: this };

args = args || {};

this.resourceGroup = args.resourceGroup || new azure.core.ResourceGroup(`${name}`, {
location: region,
}, parentArgs);

const resourceGroupArgs = {
resourceGroupName: this.resourceGroup.name,
location: this.resourceGroup.location,
};

this.storageAccount = args.storageAccount || new azure.storage.Account(`${name}`, {
...resourceGroupArgs,

accountKind: "StorageV2",
accountTier: "Standard",
accountReplicationType: "LRS",
}, parentArgs);

this.storageContainer = args.storageContainer || new azure.storage.Container(`${name}`, {
resourceGroupName: this.resourceGroup.name,
storageAccountName: this.storageAccount.name,
containerAccessType: "private",
}, parentArgs);

this.appServicePlan = args.appServicePlan || new azure.appservice.Plan(`${name}`, {
...resourceGroupArgs,

kind: "FunctionApp",

sku: {
tier: "Dynamic",
size: "Y1",
},
}, parentArgs);

const assetMap = serializeCallbackAndCreateAssetMapOutput(name, callback, bindings);

const blob = new azure.storage.ZipBlob(`${name}`, {
resourceGroupName: this.resourceGroup.name,
storageAccountName: this.storageAccount.name,
storageContainerName: this.storageContainer.name,
type: "block",

content: assetMap.apply(m => new pulumi.asset.AssetArchive(m)),
}, parentArgs);

const codeBlobUrl = signedBlobReadUrl(blob, this.storageAccount, this.storageContainer);

this.functionApp = new azure.appservice.FunctionApp(`${name}`, {
...resourceGroupArgs,

appServicePlanId: this.appServicePlan.id,
storageConnectionString: this.storageAccount.primaryConnectionString,

appSettings: {
"WEBSITE_RUN_FROM_ZIP": codeBlobUrl,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

later you'll see the storage connection string injected directly into the Binding. I think the azury way to do things is to put a setting here and map it to the connection string. then the binding just references the appsetting key.

I could make that work. it just makes things a bit more complex as the caller has to pass along those extra appsettings. But if you think i should do this, i can!

},
}, parentArgs);
}
}