Skip to content

Commit 29569bc

Browse files
Merge pull request #5 from fingerprintjs/feature/management-poc
Feature/management poc
2 parents d6d6188 + cbd1359 commit 29569bc

File tree

13 files changed

+337
-19
lines changed

13 files changed

+337
-19
lines changed

.github/workflows/build.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ jobs:
1212
steps:
1313
- name: Checkout
1414
uses: actions/checkout@v3
15+
- uses: actions/setup-node@v3
16+
with:
17+
node-version: 18
1518
- name: Get yarn cache directory path
1619
id: yarn-cache-dir-path
1720
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
@@ -30,5 +33,7 @@ jobs:
3033
run: yarn test
3134
- name: Build
3235
run: yarn build
36+
env:
37+
NODE_OPTIONS: "--max_old_space_size=4096"
3338
- name: Typecheck
3439
run: yarn test:dts

host.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
{
22
"version": "2.0",
33
"logging": {
4+
"fileLoggingMode": "always",
5+
"logLevel": {
6+
"default": "Information",
7+
"Host.Results": "Error",
8+
"Function": "Trace",
9+
"Host.Aggregator": "Trace"
10+
},
411
"applicationInsights": {
512
"samplingSettings": {
613
"isEnabled": true,
@@ -21,4 +28,4 @@
2128
"dynamicConcurrencyEnabled": true,
2229
"snapshotPersistenceEnabled": true
2330
}
24-
}
31+
}

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
module.exports = {
33
preset: 'ts-jest',
44
testEnvironment: 'node',
5-
testRegex: '/proxy/.+test.tsx?$',
5+
testRegex: '/proxy/.+test.tsx?$|/management/.+test.tsx?$',
66
passWithNoTests: true,
77
collectCoverageFrom: ['./proxy/**/**.ts', '!**/index.ts', '!**/config.ts', './management/**/**.ts'],
88
coverageReporters: ['lcov', 'json-summary', ['text', { file: 'coverage.txt', path: './' }]],

management/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const config = {
2+
repository: 'fingerprint-pro-azure-integration',
3+
repositoryOwner: 'fingerprintjs',
4+
version: '__lambda_func_version__',
5+
}

management/env.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Logger } from '@azure/functions'
2+
3+
export function getEnv(name: string) {
4+
const value = process.env[name]
5+
6+
if (!value) {
7+
throw new Error(`Missing environment variable: ${name}`)
8+
}
9+
10+
return value
11+
}
12+
13+
export function gatherEnvs(logger: Logger) {
14+
try {
15+
return {
16+
resourceGroupName: getEnv('RESOURCE_GROUP_NAME'),
17+
appName: getEnv('APP_NAME'),
18+
subscriptionId: getEnv('AZURE_SUBSCRIPTION_ID'),
19+
}
20+
} catch (error) {
21+
logger.error(`Error gathering environment variables: ${error}`)
22+
23+
return null
24+
}
25+
}

management/function.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22
"disabled": false,
33
"bindings": [
44
{
5-
"name": "blob",
6-
"type": "blobTrigger",
7-
"direction": "in",
8-
"path": "func-dist/{name}",
9-
"connection": "StorageConnectionString"
5+
"schedule": "*/30 * * * *",
6+
"name": "timer",
7+
"type": "timerTrigger",
8+
"direction": "in"
109
}
1110
],
1211
"scriptFile": "../dist/fingerprintjs-pro-azure-function-management/fingerprintjs-pro-azure-function-management.js"

management/github.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { config } from './config'
2+
import { isSemverGreater } from './semver'
3+
import { Logger } from '@azure/functions'
4+
import * as https from 'https'
5+
6+
function bearer(token?: string) {
7+
return `Bearer ${token}`
8+
}
9+
10+
export async function getLatestGithubRelease(token?: string) {
11+
const response = await fetch(
12+
`https://api.github.com/repos/${config.repositoryOwner}/${config.repository}/releases/latest`,
13+
{
14+
headers: {
15+
Authorization: bearer(token),
16+
},
17+
},
18+
)
19+
20+
return (await response.json()) as GithubRelease
21+
}
22+
23+
export async function downloadReleaseAsset(url: string, token?: string, logger?: Logger) {
24+
logger?.verbose(`Downloading release asset from ${url}`)
25+
26+
return new Promise<Buffer>((resolve, reject) => {
27+
const handleError = (error: Error) => {
28+
logger?.error('Unable to download release asset', { error })
29+
30+
reject(error)
31+
}
32+
33+
const request = https.request(
34+
url,
35+
{
36+
headers: {
37+
Authorization: bearer(token),
38+
Accept: 'application/octet-stream',
39+
'User-Agent': 'fingerprint-pro-azure-integration',
40+
},
41+
method: 'GET',
42+
},
43+
(response) => {
44+
// TODO For now, the request causes redirect, We need to check it again once repository is public
45+
if (response.statusCode === 302) {
46+
const downloadUrl = response.headers.location
47+
48+
if (!downloadUrl) {
49+
reject(new Error('Unable to find download url'))
50+
51+
return
52+
}
53+
54+
const downloadRequest = https.get(downloadUrl, (downloadResponse) => {
55+
const chunks: any[] = []
56+
57+
downloadResponse.on('data', (chunk) => {
58+
chunks.push(chunk)
59+
})
60+
61+
downloadResponse.on('end', () => {
62+
resolve(Buffer.concat(chunks))
63+
})
64+
})
65+
66+
downloadRequest.on('error', handleError)
67+
downloadRequest.end()
68+
} else {
69+
reject(new Error(`Unable to download release asset: ${response.statusCode} ${response.statusMessage}`))
70+
}
71+
},
72+
)
73+
74+
request.on('error', handleError)
75+
request.end()
76+
})
77+
}
78+
79+
export async function findFunctionZip(assets: GithubReleaseAsset[]) {
80+
return assets.find(
81+
(asset) => asset.name.endsWith('.zip') && asset.state === 'uploaded' && asset.content_type === 'application/zip',
82+
)
83+
}
84+
85+
export async function getLatestFunctionZip(logger?: Logger, token?: string) {
86+
const release = await getLatestGithubRelease(token)
87+
88+
if (!isSemverGreater(release.tag_name, config.version)) {
89+
logger?.verbose(`Latest release ${release.tag_name} is not greater than current version ${config.version}`)
90+
91+
return null
92+
}
93+
94+
const asset = await findFunctionZip(release.assets)
95+
96+
logger?.verbose(`Found asset ${asset?.name} for release ${release.tag_name}`, asset)
97+
98+
return asset
99+
? {
100+
file: await downloadReleaseAsset(asset.url, token, logger),
101+
name: asset.name,
102+
}
103+
: null
104+
}
105+
106+
export interface GithubRelease {
107+
assets_url: string
108+
url: string
109+
tag_name: string
110+
name: string
111+
assets: GithubReleaseAsset[]
112+
}
113+
114+
export interface GithubReleaseAsset {
115+
url: string
116+
name: string
117+
content_type: string
118+
state: 'uploaded' | 'errored'
119+
}

management/index.ts

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,105 @@
1-
import { AzureFunction, Context } from '@azure/functions'
1+
import { AzureFunction, Context, Timer } from '@azure/functions'
2+
import { WebSiteManagementClient } from '@azure/arm-appservice'
3+
import { DefaultAzureCredential } from '@azure/identity'
4+
import * as storageBlob from '@azure/storage-blob'
5+
import { BlobSASPermissions, StorageSharedKeyCredential } from '@azure/storage-blob'
6+
import { StorageManagementClient } from '@azure/arm-storage'
7+
import { getLatestFunctionZip } from './github'
8+
import { gatherEnvs } from './env'
29

3-
const storageBlobTrigger: AzureFunction = async (context: Context, blob: any): Promise<void> => {
4-
context.log(typeof blob)
5-
context.log(blob)
6-
context.log(context)
10+
const WEBSITE_RUN_FROM_PACKAGE = 'WEBSITE_RUN_FROM_PACKAGE'
11+
12+
const managementFn: AzureFunction = async (context: Context, timer: Timer) => {
13+
if (timer.isPastDue) {
14+
context.log('Timer function is running late!')
15+
}
16+
17+
const env = gatherEnvs(context.log)
18+
19+
if (!env) {
20+
return
21+
}
22+
23+
const { resourceGroupName, appName, subscriptionId } = env
24+
25+
const latestFunction = await getLatestFunctionZip(context.log, process.env.GITHUB_TOKEN)
26+
27+
if (!latestFunction) {
28+
context.log.info('No new release found')
29+
30+
return
31+
}
32+
33+
context.log.verbose('latestFunction', latestFunction)
34+
35+
try {
36+
const credentials = new DefaultAzureCredential()
37+
38+
const storageArmClient = new StorageManagementClient(credentials, subscriptionId)
39+
const client = new WebSiteManagementClient(credentials, subscriptionId)
40+
41+
const settings = await client.webApps.listApplicationSettings(resourceGroupName, appName)
42+
const setting = settings.properties?.[WEBSITE_RUN_FROM_PACKAGE]
43+
44+
if (setting) {
45+
context.log.verbose('storageUrl', setting)
46+
47+
const storageUrl = new URL(setting)
48+
const storageName = storageUrl.pathname.split('/')[1]
49+
const accountName = storageUrl.hostname.split('.')[0]
50+
51+
context.log.verbose('storageName', storageName)
52+
context.log.verbose('accountName', accountName)
53+
54+
const { keys } = await storageArmClient.storageAccounts.listKeys(resourceGroupName, accountName)
55+
56+
const key = keys?.[0].value
57+
58+
if (!key) {
59+
context.log.warn('No storage keys found')
60+
61+
return
62+
}
63+
64+
const containerUrl = `${storageUrl.origin}/${storageName}`
65+
const storageClient = new storageBlob.ContainerClient(
66+
containerUrl,
67+
// We must use StorageSharedKeyCredential in order to generate SAS tokens
68+
new StorageSharedKeyCredential(accountName, key),
69+
)
70+
71+
const blobClient = storageClient.getBlockBlobClient(latestFunction.name)
72+
73+
await blobClient.uploadData(latestFunction.file)
74+
75+
const sas = await blobClient.generateSasUrl({
76+
startsOn: new Date(),
77+
expiresOn: getSasExpiration(),
78+
permissions: BlobSASPermissions.from({
79+
read: true,
80+
}),
81+
})
82+
83+
context.log.verbose('sas', sas)
84+
85+
settings.properties![WEBSITE_RUN_FROM_PACKAGE] = sas
86+
87+
// TODO Add healthcheck, if it fails, revert to previous version, otherwise, delete previous version
88+
await client.webApps.updateApplicationSettings(resourceGroupName, appName, settings)
89+
}
90+
} catch (error) {
91+
context.log.error(error)
92+
}
93+
}
94+
95+
function getSasExpiration() {
96+
// By default, when deploying using Azure CLI they generate a SAS token that expires in 10 years
97+
const expirationYears = 10
98+
const expiresOn = new Date()
99+
100+
expiresOn.setFullYear(expiresOn.getFullYear() + expirationYears)
101+
102+
return expiresOn
7103
}
8104

9-
export default storageBlobTrigger
105+
export default managementFn

management/semver.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { isSemverGreater } from './semver'
2+
3+
describe('isSemverGreater', () => {
4+
it.each([
5+
{
6+
left: '1.0.0',
7+
right: '1.0.0',
8+
expected: false,
9+
},
10+
{
11+
left: '1.0.0',
12+
right: '1.0.1',
13+
expected: false,
14+
},
15+
{
16+
left: '1.0.1',
17+
right: '1.0.0',
18+
expected: true,
19+
},
20+
{
21+
left: '1.0.0',
22+
right: '1.1.0',
23+
expected: false,
24+
},
25+
{
26+
left: '1.1.0',
27+
right: '1.0.0',
28+
expected: true,
29+
},
30+
{
31+
left: '1.0.0',
32+
right: '2.0.0',
33+
expected: false,
34+
},
35+
{
36+
left: '2.0.0',
37+
right: '1.0.0',
38+
expected: true,
39+
},
40+
{
41+
left: 'v2.0.0',
42+
right: '1.0.0',
43+
expected: true,
44+
},
45+
])('should return true if left version is greater than right one', (testCase) => {
46+
expect(isSemverGreater(testCase.left, testCase.right)).toBe(testCase.expected)
47+
})
48+
})

management/semver.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { compare } from 'semver'
2+
3+
export function isSemverGreater(left: string, right: string): boolean {
4+
try {
5+
return compare(left, right) === 1
6+
} catch {
7+
return false
8+
}
9+
}

0 commit comments

Comments
 (0)