-
Notifications
You must be signed in to change notification settings - Fork 54
feat(azblob): add azure blob storage provider #257
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
base: dev
Are you sure you want to change the base?
Changes from all commits
e055b1c
d936010
3936c99
a3bf7b6
396b3d4
de315e6
8c4d1e6
feefec7
dec1b55
b64aa95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,12 @@ import { | |
| } from '@aws-sdk/client-s3' | ||
| import { Upload as S3Upload } from '@aws-sdk/lib-storage' | ||
| import { getSignedUrl } from '@aws-sdk/s3-request-presigner' | ||
| import { DefaultAzureCredential } from '@azure/identity' | ||
| import { | ||
| BlobSASPermissions, | ||
| BlobServiceClient, | ||
| generateBlobSASQueryParameters, | ||
| } from '@azure/storage-blob' | ||
| import { Storage as GcsClient } from '@google-cloud/storage' | ||
| import { NodeHttpHandler } from '@smithy/node-http-handler' | ||
| import { sql } from 'kysely' | ||
|
|
@@ -76,6 +82,7 @@ export class Storage { | |
| .with({ STORAGE_DRIVER: 's3' }, S3Adapter.fromEnv) | ||
| .with({ STORAGE_DRIVER: 'filesystem' }, FileSystemAdapter.fromEnv) | ||
| .with({ STORAGE_DRIVER: 'gcs' }, GcsAdapter.fromEnv) | ||
| .with({ STORAGE_DRIVER: 'azblob' }, AzBlobAdapter.fromEnv) | ||
| .exhaustive() | ||
| } | ||
|
|
||
|
|
@@ -1257,3 +1264,163 @@ class GcsAdapter implements StorageAdapter { | |
| .then((res) => res[0]) | ||
| } | ||
| } | ||
|
|
||
| class AzBlobAdapter implements StorageAdapter { | ||
| static async fromEnv(env: Extract<Env, { STORAGE_DRIVER: 'azblob' }>) { | ||
| const account = env.STORAGE_AZBLOB_ACCOUNT | ||
| const container = env.STORAGE_AZBLOB_CONTAINER | ||
|
|
||
| const client = env.STORAGE_AZBLOB_CONNECTION_STRING | ||
| ? BlobServiceClient.fromConnectionString(env.STORAGE_AZBLOB_CONNECTION_STRING) | ||
| : new BlobServiceClient( | ||
| env.STORAGE_AZBLOB_ENDPOINT ?? `https://${account}.blob.core.windows.net`, | ||
| new DefaultAzureCredential(), | ||
| ) | ||
|
|
||
| const containerClient = client.getContainerClient(container) | ||
| await containerClient.createIfNotExists() | ||
|
|
||
| return new AzBlobAdapter({ | ||
| client, | ||
| account, | ||
| container, | ||
| }) | ||
| } | ||
|
|
||
| private client | ||
| private account | ||
| private container | ||
| private keyPrefix = 'gh-actions-cache' | ||
|
|
||
| constructor({ | ||
| client, | ||
| account, | ||
| container, | ||
| }: { | ||
| client: BlobServiceClient | ||
| account: string | ||
| container: string | ||
| }) { | ||
| this.client = client | ||
| this.account = account | ||
| this.container = container | ||
| } | ||
|
|
||
| private get containerClient() { | ||
| return this.client.getContainerClient(this.container) | ||
| } | ||
|
|
||
| private blobKey(objectName: string) { | ||
| return `${this.keyPrefix}/${objectName}` | ||
| } | ||
|
|
||
| async createDownloadStream(objectName: string): Promise<Readable> { | ||
| const blockBlobClient = this.containerClient.getBlockBlobClient(this.blobKey(objectName)) | ||
| const response = await blockBlobClient.download() | ||
| if (!response.readableStreamBody) throw new Error(`No stream for blob "${objectName}"`) | ||
| return Readable.from(response.readableStreamBody) | ||
| } | ||
|
|
||
| async uploadStream(objectName: string, stream: AsyncIterable<Uint8Array>): Promise<void> { | ||
| const blockBlobClient = this.containerClient.getBlockBlobClient(this.blobKey(objectName)) | ||
| // TODO: consider blockSize / concurrency tuning similar to S3Upload options | ||
| await blockBlobClient.uploadStream(Readable.from(stream)) | ||
| } | ||
|
|
||
| async objectExists(objectName: string): Promise<boolean> { | ||
| return this.containerClient.getBlobClient(this.blobKey(objectName)).exists() | ||
| } | ||
|
|
||
| async deleteByPrefix(prefix: string): Promise<StorageDeletion> { | ||
| // Azure caps a batch at 256 subrequests - align LIST paging with that so each page = one batch. | ||
| const BATCH_SIZE = 256 | ||
| const deleted = { objects: 0, bytes: 0 } | ||
| const batchClient = this.containerClient.getBlobBatchClient() | ||
|
|
||
| const pages = this.containerClient.listBlobsFlat({ prefix }).byPage({ maxPageSize: BATCH_SIZE }) | ||
|
|
||
| for await (const page of pages) { | ||
| const blobs = page.segment.blobItems | ||
| if (blobs.length === 0) continue | ||
|
|
||
| const clients = blobs.map((blob) => { | ||
| deleted.objects += 1 | ||
| deleted.bytes += blob.properties.contentLength ?? 0 | ||
| return this.containerClient.getBlobClient(blob.name) | ||
| }) | ||
|
|
||
| await batchClient.deleteBlobs(clients) | ||
| } | ||
|
|
||
| return deleted | ||
| } | ||
|
|
||
| async deleteFolder(folderName: string): Promise<StorageDeletion> { | ||
| return this.deleteByPrefix(`${this.blobKey(folderName)}/`) | ||
| } | ||
|
|
||
| async clear(): Promise<void> { | ||
| await this.deleteByPrefix(this.blobKey('')) | ||
| } | ||
|
|
||
| async countFilesInFolder(folderName: string): Promise<number> { | ||
| let count = 0 | ||
| const blobs = this.containerClient.listBlobsFlat({ | ||
| prefix: `${this.blobKey(folderName)}/`, | ||
| }) | ||
| for await (const _ of blobs) { | ||
| count++ | ||
| } | ||
| return count | ||
| } | ||
|
|
||
| async getFolderSize(folderName: string): Promise<number> { | ||
| const blobs = this.containerClient.listBlobsFlat({ | ||
| prefix: `${this.blobKey(folderName)}/`, | ||
| }) | ||
| let size = 0 | ||
| for await (const blob of blobs) { | ||
| size += blob.properties.contentLength ?? 0 | ||
| } | ||
| return size | ||
| } | ||
|
|
||
| async listStorageFolders(): Promise<StorageFolder[]> { | ||
| const folders = new Map<string, StorageFolder>() | ||
| const prefix = this.blobKey('') | ||
|
|
||
| const blobs = this.containerClient.listBlobsFlat({ prefix }) | ||
| for await (const blob of blobs) { | ||
| const relativeName = blob.name.slice(prefix.length) | ||
| const folderName = relativeName.split('/', 1)[0] | ||
| if (!folderName) continue | ||
|
|
||
| const size = blob.properties.contentLength ?? 0 | ||
| const updatedAt = blob.properties.lastModified?.getTime() ?? 0 | ||
| accumulateFolder(folders, folderName, size, updatedAt) | ||
| } | ||
|
|
||
| return [...folders.values()] | ||
| } | ||
|
|
||
| async createDownloadUrl(objectName: string, expiresAt: number): Promise<string> { | ||
| const startsOn = new Date() | ||
| const expiresOn = new Date(expiresAt) | ||
|
|
||
| const delegationKey = await this.client.getUserDelegationKey(startsOn, expiresOn) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like I tried Also, setting |
||
|
|
||
| const sasParams = generateBlobSASQueryParameters( | ||
| { | ||
| containerName: this.container, | ||
| blobName: this.blobKey(objectName), | ||
| permissions: BlobSASPermissions.parse('r'), | ||
| startsOn, | ||
| expiresOn, | ||
| }, | ||
| delegationKey, | ||
| this.account, | ||
| ) | ||
|
|
||
| return `https://${this.account}.blob.core.windows.net/${this.container}/${this.blobKey(objectName)}?${sasParams.toString()}` | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one ignores |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the connection string already includes the account name, it'd be nice not to require
STORAGE_AZBLOB_ACCOUNTas well. Maybe split this into a connection string branch and an account +DefaultAzureCredentialbranch? The adapter can get the account name fromclient.accountName.