Skip to content

Commit

Permalink
chore: migrate to bring your own azure function app (#1417)
Browse files Browse the repository at this point in the history
Fixes #1343. As outlined there, the azure cli currently doesn't provide
means to link a specific environment of a static web app to a function
app. This is why we use the python sdk directly. Maybe once
Azure/azure-cli#23894 is merged and released, we
can remove the script and use the cli.
  • Loading branch information
tobiasdiez committed Sep 16, 2022
1 parent 6512f18 commit 42993d0
Show file tree
Hide file tree
Showing 4 changed files with 293 additions and 19 deletions.
14 changes: 14 additions & 0 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
To test these scripts locally, run

```bash
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
sudo apt-get install python3 pip python3-venv
python3 -m venv .venv
source .venv/bin/activate
pip install azure-identity azure-mgmt-web azure-mgmt-storage azure-mgmt-applicationinsights azure-mgmt-redis
az login
export SUBSCRIPTION_ID=<...>
export DATABASE_URL=<...>
export AZURE_SESSION_SECRET=<...>
python3 .github/scripts/deploy.py --env dev -v
```
203 changes: 203 additions & 0 deletions .github/scripts/deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# type: ignore

import os

from azure.identity import DefaultAzureCredential
from azure.mgmt.web import WebSiteManagementClient
from azure.mgmt.web.models import (
StaticSiteUserProvidedFunctionAppARMResource,
Site,
SiteConfig,
)
from azure.mgmt.storage import StorageManagementClient
from azure.mgmt.applicationinsights import ApplicationInsightsManagementClient
from azure.mgmt.redis import RedisManagementClient
from argparse import ArgumentParser
import logging


def main(environment_name: str, verbose: bool = False):
logger = logging.getLogger("script")
logger.addHandler(logging.StreamHandler(stream=os.sys.stdout))
if verbose:
logger.setLevel(level=logging.DEBUG)
else:
logger.setLevel(level=logging.INFO)

logger.info(f"Deploying to {environment_name}")

SUBSCRIPTION_ID = os.environ.get("SUBSCRIPTION_ID", None)
if SUBSCRIPTION_ID is None:
raise Exception("No subscription ID found")
GROUP_NAME = "JabRefOnline"
STATIC_SITE = "jabref-online"
STORAGE_ACCOUNT = "jabreffunctionstorage"
APP_INSIGHTS_NAME = "jabref-online"
REDIS_NAME = "jabref"
DATABASE_URL = os.environ.get("DATABASE_URL", "<Not specified>")
SESSION_SECRET = os.environ.get("AZURE_SESSION_SECRET", "<Not specified>")

function_app_name = "jabref-function-" + environment_name

# Create clients
# For other authentication approaches
# see: https://pypi.org/project/azure-identity/
web_client = WebSiteManagementClient(
credential=DefaultAzureCredential(), subscription_id=SUBSCRIPTION_ID
)
storage_client = StorageManagementClient(
credential=DefaultAzureCredential(), subscription_id=SUBSCRIPTION_ID
)
appinsights_client = ApplicationInsightsManagementClient(
credential=DefaultAzureCredential(), subscription_id=SUBSCRIPTION_ID
)
redis_client = RedisManagementClient(
credential=DefaultAzureCredential(), subscription_id=SUBSCRIPTION_ID
)

# Get info for static site (only for debug)
# static_site = web_client.static_sites.get_static_site(
# resource_group_name=GROUP_NAME, name=STATIC_SITE
# )
# print("Get static site:\n{}".format(static_site))
# builds = web_client.static_sites.get_static_site_builds(
# resource_group_name=GROUP_NAME, name=STATIC_SITE
# )
# for build in builds:
# print("Get build:\n{}".format(build))

storage_keys = storage_client.storage_accounts.list_keys(
resource_group_name=GROUP_NAME, account_name=STORAGE_ACCOUNT
)
storage_connection_string = f"DefaultEndpointsProtocol=https;EndpointSuffix=core.windows.net;AccountName={STORAGE_ACCOUNT};AccountKey={storage_keys.keys[0].value}"
logger.debug(f"Storage connection string: {storage_connection_string}")

appinsights = appinsights_client.components.get(
GROUP_NAME, APP_INSIGHTS_NAME
)
logger.debug(
f"Application insights instrumentation key: {appinsights.instrumentation_key}"
)
redis = redis_client.redis.get(
resource_group_name=GROUP_NAME, name=REDIS_NAME
)
logger.debug(f"Redis client: {redis}")
redis_keys = redis_client.redis.list_keys(
resource_group_name=GROUP_NAME, name=REDIS_NAME
)
logger.debug(f"Redis keys: {redis_keys}")

poller_function_app = web_client.web_apps.begin_create_or_update(
resource_group_name=GROUP_NAME,
name=function_app_name,
site_envelope=Site(
location="westeurope",
kind="functionapp",
site_config=SiteConfig(
app_settings=[
{"name": "FUNCTIONS_EXTENSION_VERSION", "value": "~4"},
{"name": "FUNCTIONS_WORKER_RUNTIME", "value": "node"},
{"name": "WEBSITE_NODE_DEFAULT_VERSION", "value": "~18"},
{
"name": "APPINSIGHTS_INSTRUMENTATIONKEY",
"value": appinsights.instrumentation_key,
},
{
"name": "AzureWebJobsStorage",
"value": storage_connection_string,
},
{
"name": "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING",
"value": storage_connection_string,
},
{"name": "DATABASE_URL", "value": DATABASE_URL},
{"name": "NODE_ENV", "value": "production"},
{
"name": "REDIS_HOST",
"value": redis.host_name,
},
{
"name": "REDIS_PASSWORD",
"value": redis_keys.primary_key,
},
{"name": "SESSION_SECRET_PRIMARY", "value": SESSION_SECRET},
# {
# "name": "WEBSITE_CONTENTSHARE",
# "value": "[concat(toLower(parameters('name')), 'b215')]",
# },
]
),
),
)
function_app = poller_function_app.result()
logger.info(f"Created function app: {function_app.name}")
logger.debug(f"{function_app}")

# We can even link to a certain slot in an function app
# however, we are currently limited to 2 slots per app, so this doesn't make much sense for PRs
# https://docs.microsoft.com/en-us/azure/azure-functions/functions-scale#service-limits
# but maybe for production + staging with swapping this might be handy in the future
# poller_function_app_slot = web_client.web_apps.begin_create_or_update_slot(
# resource_group_name=GROUP_NAME,
# name=function_app_name,
# site_envelope=Site(
# server_farm_id=function_app.server_farm_id,
# location=function_app.location,
# ),
# slot="test",
# )
# function_app_slot = poller_function_app_slot.result()
# print("Created function app slot:\n{}".format(function_app_slot))

# Detach already attached function apps
linked_function_apps = web_client.static_sites.get_user_provided_function_apps_for_static_site_build(
resource_group_name=GROUP_NAME,
name=STATIC_SITE,
environment_name=environment_name,
)
for app in linked_function_apps:
logger.info(f"Detaching function app {app.name}")
logger.debug(f"{app}")
web_client.static_sites.detach_user_provided_function_app_from_static_site_build(
resource_group_name=GROUP_NAME,
name=STATIC_SITE,
environment_name=environment_name,
function_app_name=app.name,
)

# Attach new function app
poller_link = web_client.static_sites.begin_register_user_provided_function_app_with_static_site_build(
resource_group_name=GROUP_NAME,
name=STATIC_SITE,
environment_name=environment_name,
function_app_name=function_app_name,
static_site_user_provided_function_envelope=StaticSiteUserProvidedFunctionAppARMResource(
kind="functionapp",
function_app_resource_id=function_app.id,
# function_app_resource_id=function_app_slot.id,
function_app_region=function_app.location,
),
)
logger.info(
f"Linked function app {function_app_name} to {environment_name}"
)
logger.debug(f"{poller_link.result()}")


if __name__ == "__main__":
parser = ArgumentParser()
parser.add_argument(
"--env",
dest="environment_name",
required=True,
help="name of the environment to deploy to",
)
# add verbose flag
parser.add_argument(
"-v",
"--verbose",
help="increase output verbosity",
action="store_true",
)
args = parser.parse_args()
main(**vars(args))
92 changes: 73 additions & 19 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,58 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}

- name: Build & Deploy
id: builddeploy
- name: Setup Node.js
uses: actions/setup-node@v3.4.1
with:
node-version: 18
cache: 'yarn'

- name: Build
run: |
yarn install
yarn build:azure
env:
DATABASE_URL: ${{ secrets.AZURE_TEST_DATABASE_URL }}

- name: Login to Azure
uses: Azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}

- name: Create & Link Function App
run: |
sudo apt-get install python3 pip python3-venv
python3 -m venv .venv
source .venv/bin/activate
pip install azure-identity azure-mgmt-web azure-mgmt-storage azure-mgmt-applicationinsights azure-mgmt-redis
python3 .github/scripts/deploy.py --env ${{ github.event.pull_request.number }}
env:
SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
DATABASE_URL: ${{ secrets.AZURE_TEST_DATABASE_URL }}
AZURE_SESSION_SECRET: ${{ secrets.AZURE_SESSION_SECRET }}

- name: Deploy Web App
id: deploy_web
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_MANGO_PEBBLE_0224C3803 }}
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
action: 'upload'
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: '/' # App source code path
api_location: '.output/server' # Api source code path - optional
output_location: '.output/public' # Built app content directory - optional
app_build_command: 'yarn build:azure'
skip_app_build: true # Use output from previous step
app_location: '.output/public' # Built app content directory
api_location: '' # Api source code path - BYOF: needs to be empty
output_location: '' # When reusing built, this needs to be empty
###### End of Repository/Build Configurations ######
env:
DATABASE_URL: ${{ secrets.AZURE_TEST_DATABASE_URL }}

- name: Deploy Function App
run: |
(cd .output/server; zip -r ../server.zip *)
az functionapp deployment source config-zip -g JabRefOnline -n jabref-function-${{ github.event.pull_request.number }} --src .output/server.zip
- name: Run API tests
run: yarn test:api --env-var='base_url=${{ steps.builddeploy.outputs.static_web_app_url }}/api' || true
run: yarn test:api --env-var='base_url=${{ steps.deploy_web.outputs.static_web_app_url }}/api'

build_and_deploy:
name: Build & Deploy to ${{ matrix.environment }}
Expand All @@ -72,6 +105,7 @@ jobs:
environment:
name: ${{ matrix.environment }}
url: ${{ matrix.url }}
DATABASE_URL: ${{ matrix.environment == 'Test' && secrets.AZURE_TEST_DATABASE_URL || secrets.AZURE_DATABASE_URL }}

steps:
- name: Checkout
Expand All @@ -80,7 +114,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3.4.1
with:
node-version: 16
node-version: 18
cache: 'yarn'

- name: Install dependencies
Expand All @@ -89,17 +123,32 @@ jobs:
- name: Reset Test database on Azure
if: matrix.environment == 'Test'
run: yarn prisma:migrate:reset --force
env:
DATABASE_URL: ${{ secrets.AZURE_TEST_DATABASE_URL }}

- name: Update Production database on Azure
if: matrix.environment == 'Staging'
run: yarn prisma:migrate:deploy

- name: Build
run: yarn build:azure

- name: Login to Azure
uses: Azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}

- name: Create & Link Function App
run: |
sudo apt-get install python3 pip python3-venv
python3 -m venv .venv
source .venv/bin/activate
pip install azure-identity azure-mgmt-web azure-mgmt-storage azure-mgmt-applicationinsights azure-mgmt-redis
python3 .github/scripts/deploy.py --env ${{ github.event.pull_request.number }}
env:
DATABASE_URL: ${{ secrets.AZURE_DATABASE_URL }}
SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_SESSION_SECRET: ${{ secrets.AZURE_SESSION_SECRET }}

- name: Build & Deploy
id: builddeploy
- name: Deploy Web App
id: deploy_web
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_MANGO_PEBBLE_0224C3803 }}
Expand All @@ -108,12 +157,17 @@ jobs:
deployment_environment: ${{ matrix.deployment_environment }}
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: '/' # App source code path
api_location: '.output/server' # Api source code path - optional
output_location: '.output/public' # Built app content directory - optional
app_build_command: 'yarn build:azure'
skip_app_build: true # Use output from previous step
app_location: '.output/public' # Built app content directory
api_location: '' # Api source code path - BYOF: needs to be empty
output_location: '' # When reusing built, this needs to be empty
###### End of Repository/Build Configurations ######

- name: Deploy Function App
run: |
(cd .output/server; zip -r ../server.zip *)
az functionapp deployment source config-zip -g JabRefOnline -n jabref-function-${{ github.event.pull_request.number }} --src .output/server.zip
- name: Run API tests
run: yarn test:api --env-var='base_url=${{ matrix.url }}/api'

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,6 @@ apollo/fragment-masking.ts
!.yarn/releases
!.yarn/sdks
!.yarn/versions

# Python
.venv/*

0 comments on commit 42993d0

Please sign in to comment.