-
Notifications
You must be signed in to change notification settings - Fork 0
feat: #8 Add contact Function App with host profiles #15
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
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
baa2af1
feat: #8 Add contact Function App with host profiles
patoperpetua d1418d5
fix: #8 Address CodeRabbit review on contact Function App
patoperpetua c7d44af
feat: #8 Load Function settings from App Config
patoperpetua a0ce75b
fix: #8 Address CodeRabbit App Config review
patoperpetua 172578c
fix: #8 Lengthen KV RBAC wait and strip XFF ports
patoperpetua File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| # PostKit Function App — Linux Consumption in rg-ssd-global. | ||
| # Secrets: NEVER in GitHub Secrets. OIDC → Key Vault `forwardemail-api-key`. | ||
| # Required Variables: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID. | ||
| # If OIDC Variables are missing, the deploy job is skipped (workflow succeeds). | ||
| name: Deploy API (Function) | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - 'apps/api/**' | ||
| - 'packages/post-kit-email/**' | ||
| - 'infra/function-app.bicep' | ||
| - 'infra/appconfig-seed.json' | ||
| - 'scripts/seed-appconfig.sh' | ||
| - '.github/workflows/deploy-api.yml' | ||
| workflow_dispatch: | ||
|
|
||
| concurrency: | ||
| group: deploy-api-production | ||
| cancel-in-progress: false | ||
|
|
||
| env: | ||
| AZURE_RESOURCE_GROUP: rg-ssd-global | ||
| AZURE_FUNCTIONAPP_NAME: ssd-postkit-api-prod-ae | ||
| AZURE_KEY_VAULT_NAME: ssd-global-kv-prod-ae | ||
| APP_CONFIG_NAME: ssd-postkit-appcs-prod-ae | ||
|
|
||
| jobs: | ||
| build: | ||
| runs-on: ubuntu-latest | ||
| name: Build ZIP | ||
| permissions: | ||
| contents: read | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - uses: pnpm/action-setup@v4 | ||
| with: | ||
| version: 9.15.0 | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 22 | ||
| cache: pnpm | ||
|
|
||
| - name: Install | ||
| run: pnpm install --frozen-lockfile | ||
|
|
||
| - name: Test API | ||
| run: pnpm --filter @singleton-sd/post-kit-api run test | ||
|
|
||
| - name: Stage zip | ||
| run: | | ||
| set -euo pipefail | ||
| pnpm --filter @singleton-sd/post-kit-email run build | ||
| pnpm --filter @singleton-sd/post-kit-api run build | ||
| STAGE=$(mktemp -d) | ||
| pnpm --filter @singleton-sd/post-kit-api deploy --prod "$STAGE" | ||
| cp apps/api/host.json "$STAGE/" | ||
| test -f "$STAGE/dist/index.js" | ||
| test -d "$STAGE/node_modules/@singleton-sd/post-kit-email" | ||
| (cd "$STAGE" && zip -r "$GITHUB_WORKSPACE/post-kit-api.zip" .) | ||
|
|
||
| - uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: post-kit-api-zip | ||
| path: post-kit-api.zip | ||
| if-no-files-found: error | ||
|
|
||
| deploy: | ||
| needs: build | ||
| if: ${{ vars.AZURE_CLIENT_ID != '' && vars.AZURE_TENANT_ID != '' && vars.AZURE_SUBSCRIPTION_ID != '' }} | ||
| runs-on: ubuntu-latest | ||
| name: Function App | ||
| permissions: | ||
| contents: read | ||
| id-token: write | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - uses: actions/download-artifact@v4 | ||
| with: | ||
| name: post-kit-api-zip | ||
|
|
||
| - name: Azure login (OIDC) | ||
| uses: azure/login@v2 | ||
| with: | ||
| client-id: ${{ vars.AZURE_CLIENT_ID }} | ||
| tenant-id: ${{ vars.AZURE_TENANT_ID }} | ||
| subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} | ||
|
|
||
| - name: Deploy Function App infra | ||
| run: | | ||
| set -euo pipefail | ||
| PRINCIPAL_ID=$(az ad sp show --id "${{ vars.AZURE_CLIENT_ID }}" --query id -o tsv) | ||
| az deployment group create \ | ||
| --resource-group "$AZURE_RESOURCE_GROUP" \ | ||
| --template-file infra/function-app.bicep \ | ||
| --name "postkit-api-${GITHUB_RUN_ID}" \ | ||
| --parameters githubOidcPrincipalId="$PRINCIPAL_ID" | ||
|
|
||
| - name: Assert KV secret exists | ||
| run: | | ||
| set -euo pipefail | ||
| deadline=$((SECONDS + 600)) | ||
| attempt=0 | ||
| while [ "$SECONDS" -lt "$deadline" ]; do | ||
| attempt=$((attempt + 1)) | ||
| if az keyvault secret show \ | ||
| --vault-name "$AZURE_KEY_VAULT_NAME" \ | ||
| --name forwardemail-api-key \ | ||
| --query name -o tsv >/dev/null; then | ||
| exit 0 | ||
| fi | ||
| remaining=$((deadline - SECONDS)) | ||
| echo "Key Vault read not ready yet (attempt ${attempt}; ${remaining}s remaining); waiting for RBAC." | ||
| sleep 20 | ||
| done | ||
| echo "forwardemail-api-key is missing or the OIDC principal cannot read it." | ||
| exit 1 | ||
|
|
||
| - name: Seed App Configuration (missing keys only) | ||
| run: | | ||
| set -euo pipefail | ||
| chmod +x scripts/seed-appconfig.sh | ||
| ./scripts/seed-appconfig.sh infra/appconfig-seed.json | ||
|
|
||
| - name: Zip deploy Function App | ||
| run: | | ||
| set -euo pipefail | ||
| az functionapp deployment source config-zip \ | ||
| --resource-group "$AZURE_RESOURCE_GROUP" \ | ||
| --name "$AZURE_FUNCTIONAPP_NAME" \ | ||
| --src post-kit-api.zip | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,5 @@ | ||
| # Apps | ||
|
|
||
| Azure Functions API will live here as `apps/api`. | ||
|
|
||
| That app is not created in this bootstrap PR. Later epics add the contact/send | ||
| Function App that trusted consumers call. | ||
| | App | Package | Role | | ||
| | --- | --- | --- | | ||
| | [`api`](./api/) | `@singleton-sd/post-kit-api` | Azure Functions: `POST /contact`, `GET /health` | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| dist/ | ||
| local.settings.json |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| # `@singleton-sd/post-kit-api` | ||
|
|
||
| Azure Functions (anonymous contact + health). Trusted marketing sites POST | ||
| `/contact` with an allowlisted `Origin`. Host-specific sender/inbox and other | ||
| non-secret settings come from Azure App Configuration | ||
| (`ssd-postkit-appcs-prod-ae`). `FORWARD_EMAIL_TOKEN` is a Key Vault reference | ||
| in that store. | ||
|
|
||
| Local `func start` needs `az login`, | ||
| `AZURE_APPCONFIGURATION_ENDPOINT` in `local.settings.json` (see the example), | ||
| and these Azure RBAC roles on your user: | ||
|
|
||
| - **App Configuration Data Reader** on `ssd-postkit-appcs-prod-ae` | ||
| - **Key Vault Secrets User** on `ssd-global-kv-prod-ae` | ||
|
|
||
| `az login` only supplies a credential; without both roles the contact handler | ||
| cannot load configuration. Do not put tenant profiles or tokens in | ||
| `local.settings.json`. | ||
|
|
||
| ```bash | ||
| pnpm --filter @singleton-sd/post-kit-api test | ||
| pnpm --filter @singleton-sd/post-kit-api start | ||
| ``` | ||
|
|
||
| See [`docs/email-forward-email.md`](../../docs/email-forward-email.md) and | ||
| [`infra/README.md`](../../infra/README.md). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| { | ||
| "version": "2.0", | ||
| "logging": { | ||
| "applicationInsights": { | ||
| "samplingSettings": { | ||
| "isEnabled": true, | ||
| "excludedTypes": "Request" | ||
| } | ||
| } | ||
| }, | ||
| "extensionBundle": { | ||
| "id": "Microsoft.Azure.Functions.ExtensionBundle", | ||
| "version": "[4.*, 5.0.0)" | ||
| }, | ||
| "extensions": { | ||
| "http": { | ||
| "routePrefix": "" | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| { | ||
| "IsEncrypted": false, | ||
| "Values": { | ||
| "AzureWebJobsStorage": "UseDevelopmentStorage=true", | ||
| "FUNCTIONS_WORKER_RUNTIME": "node", | ||
| "AZURE_APPCONFIGURATION_ENDPOINT": "https://ssd-postkit-appcs-prod-ae.azconfig.io" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| { | ||
| "name": "@singleton-sd/post-kit-api", | ||
| "version": "0.1.0", | ||
| "private": true, | ||
| "main": "dist/index.js", | ||
| "scripts": { | ||
| "build": "pnpm --filter @singleton-sd/post-kit-email run build && tsc -p tsconfig.json", | ||
| "lint": "echo \"lint:api — covered by root eslint on staged files\"", | ||
| "test": "pnpm --filter @singleton-sd/post-kit-email run build && pnpm build && node --import tsx --test \"src/**/*.spec.ts\"", | ||
| "start": "func start" | ||
| }, | ||
| "dependencies": { | ||
| "@azure/app-configuration": "^1.12.1", | ||
| "@azure/functions": "^4.6.0", | ||
| "@azure/identity": "^4.13.1", | ||
| "@azure/keyvault-secrets": "^4.11.2", | ||
| "@singleton-sd/post-kit-email": "workspace:*" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^22.10.2", | ||
| "tsx": "^4.19.2", | ||
| "typescript": "^5.7.2" | ||
| }, | ||
| "engines": { | ||
| "node": ">=22" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { describe, it, beforeEach, afterEach } from 'node:test'; | ||
| import type { ConfigurationSetting } from '@azure/app-configuration'; | ||
| import { | ||
| ensureAppConfiguration, | ||
| loadAppConfiguration, | ||
| resetAppConfigurationCache, | ||
| } from './app-configuration'; | ||
|
|
||
| describe('loadAppConfiguration', () => { | ||
| const touched = [ | ||
| 'AZURE_APPCONFIGURATION_ENDPOINT', | ||
| 'ORIGINS', | ||
| 'CONTACT_EMAIL_PROFILES_BY_HOST', | ||
| 'FORWARD_EMAIL_TOKEN', | ||
| 'EMAIL_FROM_ADDRESS', | ||
| ]; | ||
| const prior = new Map<string, string | undefined>(); | ||
|
|
||
| beforeEach(() => { | ||
| for (const key of touched) { | ||
| prior.set(key, process.env[key]); | ||
| delete process.env[key]; | ||
| } | ||
| resetAppConfigurationCache(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| for (const [key, value] of prior) { | ||
| if (value === undefined) delete process.env[key]; | ||
| else process.env[key] = value; | ||
| } | ||
| resetAppConfigurationCache(); | ||
| }); | ||
|
|
||
| it('does nothing when no endpoint is configured', async () => { | ||
| let listed = false; | ||
| await loadAppConfiguration({ | ||
| listSettings: () => { | ||
| listed = true; | ||
| return settings(); | ||
| }, | ||
| }); | ||
| assert.equal(listed, false); | ||
| }); | ||
|
|
||
| it('maps plain settings and Key Vault references to environment variables', async () => { | ||
| process.env.AZURE_APPCONFIGURATION_ENDPOINT = 'https://example.azconfig.io'; | ||
| const getSecret = async (secretUri: string) => { | ||
| assert.match(secretUri, /forwardemail-api-key/); | ||
| return { value: 'token-from-kv' }; | ||
| }; | ||
|
|
||
| await loadAppConfiguration({ | ||
| listSettings: () => | ||
| settings( | ||
| setting('app:email:origins', '*.poc.singletonsd.com'), | ||
| setting( | ||
| 'app:email:profilesByHost', | ||
| '{"inkads.poc.singletonsd.com":{"fromAddress":"noreply@mail.inkads.poc.singletonsd.com"}}', | ||
| ), | ||
| setting( | ||
| 'secret:forwardemail-api-key', | ||
| JSON.stringify({ | ||
| uri: 'https://ssd-global-kv-prod-ae.vault.azure.net/secrets/forwardemail-api-key', | ||
| }), | ||
| 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8', | ||
| ), | ||
| setting('unmapped:key', 'ignored'), | ||
| ), | ||
| getSecret, | ||
| }); | ||
|
|
||
| assert.equal(process.env.ORIGINS, '*.poc.singletonsd.com'); | ||
| assert.equal( | ||
| process.env.CONTACT_EMAIL_PROFILES_BY_HOST, | ||
| '{"inkads.poc.singletonsd.com":{"fromAddress":"noreply@mail.inkads.poc.singletonsd.com"}}', | ||
| ); | ||
| assert.equal(process.env.FORWARD_EMAIL_TOKEN, 'token-from-kv'); | ||
| assert.equal(process.env.UNMAPPED_KEY, undefined); | ||
| }); | ||
|
|
||
| it('preserves explicitly configured environment variables', async () => { | ||
| process.env.AZURE_APPCONFIGURATION_ENDPOINT = 'https://example.azconfig.io'; | ||
| process.env.ORIGINS = 'localhost:4321'; | ||
|
|
||
| await loadAppConfiguration({ | ||
| listSettings: () => settings(setting('app:email:origins', 'from-store')), | ||
| }); | ||
|
|
||
| assert.equal(process.env.ORIGINS, 'localhost:4321'); | ||
| }); | ||
|
|
||
| it('retries after a failed load instead of caching the rejection', async () => { | ||
| process.env.AZURE_APPCONFIGURATION_ENDPOINT = 'https://example.azconfig.io'; | ||
| let calls = 0; | ||
| const failing = { | ||
| listSettings: () => | ||
| (async function* () { | ||
| calls += 1; | ||
| throw new Error('store unavailable'); | ||
| })(), | ||
| }; | ||
|
|
||
| await assert.rejects(ensureAppConfiguration(failing), /store unavailable/); | ||
| await assert.rejects(ensureAppConfiguration(failing), /store unavailable/); | ||
| assert.equal(calls, 2); | ||
| }); | ||
|
|
||
| it('rejects malformed Key Vault references', async () => { | ||
| process.env.AZURE_APPCONFIGURATION_ENDPOINT = 'https://example.azconfig.io'; | ||
|
|
||
| await assert.rejects( | ||
| loadAppConfiguration({ | ||
| listSettings: () => | ||
| settings( | ||
| setting( | ||
| 'secret:forwardemail-api-key', | ||
| '{}', | ||
| 'application/vnd.microsoft.appconfig.keyvaultref+json', | ||
| ), | ||
| ), | ||
| }), | ||
| /Invalid Key Vault reference for secret:forwardemail-api-key/, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| function setting(key: string, value: string, contentType?: string): ConfigurationSetting { | ||
| return { key, value, contentType } as ConfigurationSetting; | ||
| } | ||
|
|
||
| async function* settings(...values: ConfigurationSetting[]) { | ||
| yield* values; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.