pulumi / pulumi-azure-native

Azure Native Provider
Apache License 2.0
126 stars 33 forks source link

Azure Functions asset support #3483

Open 1oglop1 opened 1 month ago

1oglop1 commented 1 month ago

Hello!

Issue details

Would it be possible to extend or create a completely new resource, for Azure functions deployment? The scm API seems to be poorly documented but it seems that all it takes is to POST the zip file and wait.

Motivation: Azure Functions can use WEBSITE_RUN_FROM_PACKAGE, see pulumi example https://github.com/pulumi/examples/blob/master/azure-ts-functions/index.ts, but there are hidden problems with that example:

1) when the blob object is updated (resource: replace) the blob url does not change and after the blob update the function keeps using the old code. For this problem Microsoft recommends restarting the function app - which is not possible to do via pulumi. ... there are possible workarounds, eg. cleverly update AppSettings every time the source code changes. 2) according to the documentation: WEBSITE_RUN_FROM_PACKAGE = url has a slight performance impact during the cold start to negate this, users are asked to upload zip directly to the runtime - but it can only be done via az cli or other programs implementing SCM. 3) there is a that Blob url may expire and AzF won't be able to load the code anymore. 🤦

This would improve user experience of azure-native.web.WebApp resource https://www.pulumi.com/registry/packages/azure-native/api-docs/web/webapp/

So that pulumi user could use an Asset or AssetArchive or even just a path to the zip file as an input and update.

the resource could take the same parameters as this url:

POST https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/config/publishingcredentials/list?api-version=2023-12-01

Bits around SCM API:

What azure cli does. https://github.com/Azure/azure-cli/blob/29564830498870c401679e0059fddbbf5851f10c/src/azure-cli/azure/cli/command_modules/appservice/custom.py#L771-L783

Where scm uri comes from https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/list-publishing-credentials?view=rest-appservice-2023-12-01

Affected area/feature

Azure Functions/ Web apps

danielrbradley commented 1 month ago

Thanks for getting in touch @1oglop1

I've re-titled this as I think the main ask here to be able to pass an Asset (or Archive) directly into an Azure Function, something similar to:

const app = new web.WebApp("fa", {
    resourceGroupName: resourceGroup.name,
    serverFarmId: plan.id,
    kind: "functionapp",
    source: new pulumi.asset.FileArchive("./javascript")
    siteConfig: {
        appSettings: [
            { name: "FUNCTIONS_EXTENSION_VERSION", value: "~3" },
            { name: "FUNCTIONS_WORKER_RUNTIME", value: "node" },
            { name: "WEBSITE_NODE_DEFAULT_VERSION", value: "~14" },
        ],
        http20Enabled: true,
        nodeVersion: "~14",
    },
});

To know if this could be added directly to the WebApp, or if it's a more specialised resource type, we would need to confirm if the SCM upload route is available for all kinds of WebApp.

An additional consideration to take into account is the security around the SCM endpoints. It's possible that a user could configure the security so the provider would not have access to upload to the SCM endpoint. We would need to ensure the failure mode is robust and communicates the issue effectively.

Workarounds

In the short term, I think we can improve the linked example to ensure that each upload of the code is written to a new blob, so it gets a new blob URL, and automatically triggers a re-deployment of the WebApp. This might be as simple as adding the replaceOnChanges for the codeBlob. E.g.

// Upload Azure Function's code as a zip archive to the storage account.
const codeBlob = new storage.Blob("zip", {
        resourceGroupName: resourceGroup.name,
        accountName: storageAccount.name,
        containerName: codeContainer.name,
        source: new pulumi.asset.FileArchive("./javascript"),
    }, {
        replaceOnChanges: ["source"],
    },
);

Secondly, it might be helpful to add a built-in invoke (function) to the provider for calculating the signed URL to reduce the amount of code required in user programs.

1oglop1 commented 1 month ago

@danielrbradley Thank you for updating the title!

Yes, this is the kind of API I'd like to have, eventually, it can be a second resource dependent on WebApp to simplify the implementation because functionApp does not need any code for the successful deployment of the resource itself. The code can be updated in the second step.

Unfortunately, the workaround you proposed is not working for me because:

  1. Blob is already replaced on source changes. -> The extra opts are not required.
  2. the replacement of the Blob does not propagate to WebApp because replacing the blob does not replace the URL.

Also, it is required to restart the FunctionApp when the file in the remote URL is updated: https://learn.microsoft.com/en-us/azure/azure-functions/run-functions-from-deployment-package#manually-uploading-a-package-to-blob-storage This is not required when using az cli because it is a zip deployment which triggers FunctionApp restart.

The real workaround is to use something to trigger the replacement of WebApp when Blob is replaced. https://github.com/pulumi/pulumi/issues/11577#issuecomment-1341703007

Until then, to update the blob URL the resource name of the blob has to change to propagate the change via the URL in value: codeBlobUrl or any different value which triggers the replacement.

// Upload Azure Function's code as a zip archive to the storage account.
const codeBlob = new storage.Blob("THIS_HAS_TO_CHANGE_TO_UPDATE_URL", {
        resourceGroupName: resourceGroup.name,
        accountName: storageAccount.name,
        containerName: codeContainer.name,
        source: new pulumi.asset.FileArchive("./javascript"),
    }, 
);

So I made a workaround to create a zipfile in the code and calculate the MD5 before passing the file to Blob resource.


    const jsfiles = await glob(globPattern, { cwd, absolute: false });
    const promises = jsfiles.sort().map(async (file) => {
      console.log(file);
      const fpath = path.join(cwd, file);
      const buffer = new Uint8Array(await fsAsync.readFile(fpath));
      return {
        path: file,
        buffer,
      };
    });

    const resolvedPathBuffer = await Promise.all(promises);
    const filenamesAndContents = resolvedPathBuffer.reduce((acc, { path, buffer }) => {
      return { ...acc, [path]: buffer };
    }, {});

    console.log(Object.keys(filenamesAndContents));

// zip with fixed m_time produces the same hash based on the file contents instead of the date, regardless of the system.
    const zipContent = fflate.zipSync(filenamesAndContents, {
      os: 0,
      mtime: "1987-12-26",
    });
    const hash = await calculateHash(zipContent, "md5");

    const zipPath = path.join(cwd, `../${name}.zip`);
    fs.writeFileSync(zipPath, zipContent);

    // Upload Azure Function's code as a zip archive to the storage account.
    const codeBlob = new storage.Blob(
      rcName(`codeBlob${hash}.zip`),
      {
        resourceGroupName: resourceGroup.name,
        accountName: storageAccount.name,
        containerName: codeContainer.name,
        source: new pulumi.asset.FileArchive(zipPath),
      },
      // { retainOnDelete: true } // to keep the history
    );
danielrbradley commented 1 month ago

Yes, this is the kind of API I'd like to have, eventually, it can be a second resource dependent on WebApp to simplify the implementation because functionApp does not need any code for the successful deployment of the resource itself. The code can be updated in the second step.

Actually, a second, standalone resource would be a nice design. It would avoid us needing to mix specification-derived behaviour with custom behaviour. It would then likely be implemented very similarly to the storage blob custom resource.

Unfortunately, the workaround you proposed is not working for me because:

  1. Blob is already replaced on source changes. -> The extra opts are not required.
  2. the replacement of the Blob does not propagate to WebApp because replacing the blob does not replace the URL.

Ah I can see the issue - the storage blob resource doesn't use the normal auto-naming approach of defaulting the blob name property from the resource name with a random suffix–it doesn't include the random suffix. Therefore, when it's replaced, it doesn't get a new blob name and so the URL doesn't change either.