Skip to content

Commit

Permalink
feat(monosize-storage-azure): add support for federated identity with…
Browse files Browse the repository at this point in the history
… azure pipelines (#79)
  • Loading branch information
TristanWatanabe authored Aug 6, 2024
1 parent ce8d61e commit e968bcd
Show file tree
Hide file tree
Showing 10 changed files with 466 additions and 91 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat: add support for workload identity authentication with azure pipelines.",
"packageName": "monosize-storage-azure",
"email": "[email protected]",
"dependentChangeType": "patch"
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
},
"dependencies": {
"@azure/data-tables": "^13.0.0",
"@azure/identity": "^4.4.0",
"@upstash/redis": "^1.18.0",
"acorn": "^8.11.3",
"ci-info": "^3.7.0",
Expand Down
1 change: 1 addition & 0 deletions packages/monosize-storage-azure/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"types": "./src/index.d.mts",
"dependencies": {
"@azure/data-tables": "^13.0.0",
"@azure/identity": "^4.4.0",
"monosize": "^0.6.2",
"node-fetch": "^3.3.0",
"picocolors": "^1.0.0",
Expand Down
67 changes: 67 additions & 0 deletions packages/monosize-storage-azure/src/createTableClient.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { AzureNamedKeyCredential, TableClient } from '@azure/data-tables';
import { AzurePipelinesCredential } from '@azure/identity';
import type { AzureStorageConfig } from './types.mjs';

export function createTableClient(options: Required<Pick<AzureStorageConfig, 'authType' | 'tableName'>>): TableClient {
const { authType, tableName } = options;

const AZURE_STORAGE_TABLE_NAME = tableName;

if (authType === 'AzureNamedKeyCredential') {
const requiredEnvVars = ['BUNDLESIZE_ACCOUNT_NAME', 'BUNDLESIZE_ACCOUNT_KEY'];
validateRequiredEnvVariables({
requiredEnvVars,
authType,
});

const AZURE_STORAGE_ACCOUNT = process.env['BUNDLESIZE_ACCOUNT_NAME'] as string;
const AZURE_ACCOUNT_KEY = process.env['BUNDLESIZE_ACCOUNT_KEY'] as string;

return new TableClient(
`https://${AZURE_STORAGE_ACCOUNT}.table.core.windows.net`,
AZURE_STORAGE_TABLE_NAME,
new AzureNamedKeyCredential(AZURE_STORAGE_ACCOUNT, AZURE_ACCOUNT_KEY),
);
}

if (authType === 'AzurePipelinesCredential') {
const requiredEnvVars = [
'BUNDLESIZE_ACCOUNT_NAME',
'AZURE_TENANT_ID',
'AZURE_CLIENT_ID',
'AZURE_SERVICE_CONNECTION_ID',
'SYSTEM_ACCESSTOKEN',
];
validateRequiredEnvVariables({
requiredEnvVars,
authType,
});

const AZURE_STORAGE_ACCOUNT = process.env['BUNDLESIZE_ACCOUNT_NAME'] as string;
const TENANT_ID = process.env['AZURE_TENANT_ID'] as string;
const CLIENT_ID = process.env['AZURE_CLIENT_ID'] as string;
const SERVICE_CONNECTION_ID = process.env['AZURE_SERVICE_CONNECTION_ID'] as string;
const SYSTEM_ACCESSTOKEN = process.env['SYSTEM_ACCESSTOKEN'] as string;

return new TableClient(
`https://${AZURE_STORAGE_ACCOUNT}.table.core.windows.net`,
AZURE_STORAGE_TABLE_NAME,
new AzurePipelinesCredential(TENANT_ID, CLIENT_ID, SERVICE_CONNECTION_ID, SYSTEM_ACCESSTOKEN),
);
}

throw new Error(`monosize-storage-azure: "authType: ${authType}" is not supported.`);
}

function validateRequiredEnvVariables(options: { requiredEnvVars: string[]; authType: string }): void {
const { requiredEnvVars, authType } = options;
const missingEnvVars = requiredEnvVars.filter(envParamName => typeof process.env[envParamName] !== 'string');

if (missingEnvVars.length > 0) {
throw new Error(
`monosize-storage-azure: Missing required environment variable(s) for authType ${authType}: ${missingEnvVars.join(
', ',
)} not in your process.env.`,
);
}
}
85 changes: 85 additions & 0 deletions packages/monosize-storage-azure/src/createTableClient.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vitest } from 'vitest';
import { AzureNamedKeyCredential, TableClient } from '@azure/data-tables';
import { AzurePipelinesCredential } from '@azure/identity';
import { createTableClient } from './createTableClient.mjs';
import type { AzureAuthenticationType } from './types.mjs';

vitest.mock('@azure/data-tables', () => {
return {
AzureNamedKeyCredential: vitest.fn(),
TableClient: vitest.fn().mockImplementation(() => {
return {
createTable: vitest.fn(),
deleteTable: vitest.fn(),
};
}),
};
});

vitest.mock('@azure/identity', () => {
return {
AzurePipelinesCredential: vitest.fn(),
};
});

describe('createTableClient', () => {
beforeEach(() => {
vitest.resetAllMocks();
vitest.unstubAllEnvs();
});

it('should create TableClient with AzureNamedKeyCredential', () => {
vitest.stubEnv('BUNDLESIZE_ACCOUNT_NAME', 'test-account-name');
vitest.stubEnv('BUNDLESIZE_ACCOUNT_KEY', 'test-account-key');

const authType = 'AzureNamedKeyCredential';
const tableName = 'test-table';
createTableClient({ authType, tableName });

expect(AzureNamedKeyCredential).toHaveBeenCalledWith(
process.env['BUNDLESIZE_ACCOUNT_NAME'] as string,
process.env['BUNDLESIZE_ACCOUNT_KEY'] as string,
);

expect(TableClient).toHaveBeenCalledWith(
'https://test-account-name.table.core.windows.net',
tableName,
expect.any(AzureNamedKeyCredential),
);
});

it('should create TableClient with AzurePipelinesCredential', () => {
vitest.stubEnv('BUNDLESIZE_ACCOUNT_NAME', 'test-account-name');
vitest.stubEnv('AZURE_TENANT_ID', 'test-tenant-id');
vitest.stubEnv('AZURE_CLIENT_ID', 'test-client-id');
vitest.stubEnv('AZURE_SERVICE_CONNECTION_ID', 'test-service-connection-id');
vitest.stubEnv('SYSTEM_ACCESSTOKEN', 'test-system-access-token');
vitest.stubEnv('SYSTEM_OIDCREQUESTURI', 'test-system-oidc-request-uri');

const authType = 'AzurePipelinesCredential';
const tableName = 'test-table';
createTableClient({ authType, tableName });

expect(AzurePipelinesCredential).toHaveBeenCalledWith(
process.env['AZURE_TENANT_ID'] as string,
process.env['AZURE_CLIENT_ID'] as string,
process.env['AZURE_SERVICE_CONNECTION_ID'] as string,
process.env['SYSTEM_ACCESSTOKEN'] as string,
);

expect(TableClient).toHaveBeenCalledWith(
'https://test-account-name.table.core.windows.net',
tableName,
expect.any(AzurePipelinesCredential),
);
});

it('should throw an error for unsupported authType', () => {
const authType = 'AzureNamedKeyCredentail' as AzureAuthenticationType;
const tableName = 'test-table';

expect(() => createTableClient({ authType, tableName })).toThrow(
`monosize-storage-azure: "authType: ${authType}" is not supported.`,
);
});
});
4 changes: 2 additions & 2 deletions packages/monosize-storage-azure/src/index.mts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { StorageAdapter } from 'monosize';

import { createGetRemoteReport } from './getRemoteReport.mjs';
import { uploadReportToRemote } from './uploadReportToRemote.mjs';
import { createUploadReportToRemote } from './uploadReportToRemote.mjs';
import type { AzureStorageConfig } from './types.mjs';

function createAzureStorage(config: AzureStorageConfig): StorageAdapter {
return {
getRemoteReport: createGetRemoteReport(config),
uploadReportToRemote,
uploadReportToRemote: createUploadReportToRemote(config),
};
}

Expand Down
13 changes: 13 additions & 0 deletions packages/monosize-storage-azure/src/types.mts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
/**
* @see https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/identity/identity/samples/AzureIdentityExamples.md#authenticating-azure-hosted-applications
*/
export type AzureAuthenticationType = 'AzureNamedKeyCredential' | 'AzurePipelinesCredential';

export type AzureStorageConfig = {
endpoint: string;
/**
* @default 'AzureNamedKeyCredential' auth type
*/
authType?: AzureAuthenticationType;
/**
* @default 'latest' table name
*/
tableName?: string;
};
113 changes: 54 additions & 59 deletions packages/monosize-storage-azure/src/uploadReportToRemote.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AzureNamedKeyCredential, odata, TableClient, TableTransaction } from '@azure/data-tables';
import { BundleSizeReportEntry, StorageAdapter } from 'monosize';
import { odata, TableTransaction } from '@azure/data-tables';
import { BundleSizeReportEntry, BundleSizeReport, StorageAdapter } from 'monosize';
import pc from 'picocolors';
import { createTableClient } from './createTableClient.mjs';
import type { AzureStorageConfig } from './types.mjs';

export const ENTRIES_PER_CHUNK = 90;

Expand All @@ -14,71 +16,64 @@ export function splitArrayToChunks<T>(arr: T[], size: number): T[][] {
return [...Array(Math.ceil(arr.length / size))].map((_, i) => arr.slice(i * size, (i + 1) * size));
}

export const uploadReportToRemote: StorageAdapter['uploadReportToRemote'] = async (branch, commitSHA, localReport) => {
if (typeof process.env['BUNDLESIZE_ACCOUNT_KEY'] !== 'string') {
throw new Error('monosize-storage-azure: "BUNDLESIZE_ACCOUNT_KEY" is not defined in your process.env');
}
export function createUploadReportToRemote(config: AzureStorageConfig) {
const { authType = 'AzureNamedKeyCredential', tableName = 'latest' } = config;

if (typeof process.env['BUNDLESIZE_ACCOUNT_NAME'] !== 'string') {
throw new Error('monosize-storage-azure: "BUNDLESIZE_ACCOUNT_NAME" is not defined in your process.env');
}
async function uploadReportToRemote(
branch: string,
commitSHA: string,
localReport: BundleSizeReport,
): ReturnType<StorageAdapter['uploadReportToRemote']> {
const client = createTableClient({ authType, tableName });

if (localReport.length === 0) {
console.log([pc.yellow('[w]'), 'No entries to upload'].join(' '));
return;
}
if (localReport.length === 0) {
console.log([pc.yellow('[w]'), 'No entries to upload'].join(' '));
return;
}

const transaction = new TableTransaction();
const entitiesIterator = client.listEntities({
queryOptions: {
filter: odata`PartitionKey eq ${branch}`,
},
});

const AZURE_STORAGE_ACCOUNT = process.env['BUNDLESIZE_ACCOUNT_NAME'];
const AZURE_STORAGE_TABLE_NAME = 'latest';
const AZURE_ACCOUNT_KEY = process.env['BUNDLESIZE_ACCOUNT_KEY'];

const credentials = new AzureNamedKeyCredential(AZURE_STORAGE_ACCOUNT, AZURE_ACCOUNT_KEY);
const client = new TableClient(
`https://${AZURE_STORAGE_ACCOUNT}.table.core.windows.net`,
AZURE_STORAGE_TABLE_NAME,
credentials,
);

const transaction = new TableTransaction();
const entitiesIterator = await client.listEntities({
queryOptions: {
filter: odata`PartitionKey eq ${branch}`,
},
});

for await (const entity of entitiesIterator) {
// We can't delete and create entries with the same "rowKey" in the same transaction
// => we delete only entries not present in existing report
const isEntryPresentInExistingReport = Boolean(localReport.find(entry => createRowKey(entry) === entity.rowKey));
const shouldEntryBeDeleted = !isEntryPresentInExistingReport;

if (shouldEntryBeDeleted) {
transaction.deleteEntity(entity.partitionKey as string, entity.rowKey as string);
for await (const entity of entitiesIterator) {
// We can't delete and create entries with the same "rowKey" in the same transaction
// => we delete only entries not present in existing report
const isEntryPresentInExistingReport = Boolean(localReport.find(entry => createRowKey(entry) === entity.rowKey));
const shouldEntryBeDeleted = !isEntryPresentInExistingReport;

if (shouldEntryBeDeleted) {
transaction.deleteEntity(entity.partitionKey as string, entity.rowKey as string);
}
}
}

localReport.forEach(entry => {
transaction.upsertEntity(
{
partitionKey: branch,
rowKey: createRowKey(entry),
localReport.forEach(entry => {
transaction.upsertEntity(
{
partitionKey: branch,
rowKey: createRowKey(entry),

name: entry.name,
packageName: entry.packageName,
path: entry.path,
name: entry.name,
packageName: entry.packageName,
path: entry.path,

minifiedSize: entry.minifiedSize,
gzippedSize: entry.gzippedSize,
minifiedSize: entry.minifiedSize,
gzippedSize: entry.gzippedSize,

commitSHA,
},
'Replace',
);
});
commitSHA,
},
'Replace',
);
});

const chunks = splitArrayToChunks(transaction.actions, ENTRIES_PER_CHUNK);
const chunks = splitArrayToChunks(transaction.actions, ENTRIES_PER_CHUNK);

for (const chunk of chunks) {
await client.submitTransaction(chunk);
for (const chunk of chunks) {
await client.submitTransaction(chunk);
}
}
};

return uploadReportToRemote;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { beforeEach, beforeAll, describe, expect, it, vitest, type Mock } from 'vitest';
import { createRowKey, ENTRIES_PER_CHUNK, splitArrayToChunks, uploadReportToRemote } from './uploadReportToRemote.mjs';
import {
createRowKey,
ENTRIES_PER_CHUNK,
splitArrayToChunks,
createUploadReportToRemote,
} from './uploadReportToRemote.mjs';

import { sampleReport, bigReport } from './__fixture__/sampleReports.mjs';
import { BundleSizeReportEntry } from 'monosize';
import type { AzureStorageConfig } from './types.mjs';

const getRemoteReport = vitest.hoisted(
() => vitest.fn() as Mock<Array<BundleSizeReportEntry & { partitionKey: string; rowKey: string }>>,
);
const submitTransaction = vitest.hoisted(() => vitest.fn());

const testConfig: AzureStorageConfig = {
endpoint: 'https://localhost',
authType: 'AzureNamedKeyCredential',
};

vitest.mock('@azure/data-tables', async () => {
const listEntities = () => {
const data = getRemoteReport();
Expand Down Expand Up @@ -82,6 +93,7 @@ describe('uploadReportToRemote', () => {
const localReport = sampleReport.slice(0, 1);

getRemoteReport.mockReturnValueOnce(remoteReport);
const uploadReportToRemote = createUploadReportToRemote(testConfig);
await uploadReportToRemote(branchName, commitSHA, localReport);

expect(submitTransaction).toHaveBeenCalledTimes(1);
Expand All @@ -105,6 +117,7 @@ describe('uploadReportToRemote', () => {
const localReport = bigReport;

getRemoteReport.mockReturnValueOnce(remoteReport);
const uploadReportToRemote = createUploadReportToRemote(testConfig);
await uploadReportToRemote(branchName, commitSHA, localReport);

expect(submitTransaction).toHaveBeenCalledTimes(Math.ceil(localReport.length / ENTRIES_PER_CHUNK));
Expand All @@ -122,6 +135,7 @@ describe('uploadReportToRemote', () => {
it('performs no actions if local report is empty', async () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
const log = vitest.spyOn(console, 'log').mockImplementation(() => {});
const uploadReportToRemote = createUploadReportToRemote(testConfig);
await uploadReportToRemote(branchName, commitSHA, []);

expect(log).toHaveBeenCalledTimes(1);
Expand Down
Loading

0 comments on commit e968bcd

Please sign in to comment.