diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 3b535c8..1f1639c 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - storage-driver: [filesystem, s3, gcs] + storage-driver: [filesystem, s3, gcs, azblob] db-driver: [postgres, mysql, sqlite] steps: - name: pnpm install diff --git a/install/kubernetes/github-actions-cache-server/templates/_helpers.tpl b/install/kubernetes/github-actions-cache-server/templates/_helpers.tpl index f480c9b..2dc3d82 100644 --- a/install/kubernetes/github-actions-cache-server/templates/_helpers.tpl +++ b/install/kubernetes/github-actions-cache-server/templates/_helpers.tpl @@ -193,6 +193,25 @@ Generate environment variables from config values. value: {{ .endpoint | quote }} {{- end }} {{- end }} +{{- else if eq .Values.config.storage.driver "azblob" }} +{{- with .Values.config.storage.azblob }} +{{- if .account }} +- name: STORAGE_AZBLOB_ACCOUNT + value: {{ .account | quote }} +{{- end }} +{{- if .container }} +- name: STORAGE_AZBLOB_CONTAINER + value: {{ .container | quote }} +{{- end }} +{{- if .connectionString }} +- name: STORAGE_AZBLOB_CONNECTION_STRING + value: {{ .connectionString | quote }} +{{- end }} +{{- if .endpoint }} +- name: STORAGE_AZBLOB_ENDPOINT + value: {{ .endpoint | quote }} +{{- end }} +{{- end }} {{- end }} {{/* Database driver */}} - name: DB_DRIVER diff --git a/install/kubernetes/github-actions-cache-server/values.yaml b/install/kubernetes/github-actions-cache-server/values.yaml index c25b8b6..33621e6 100644 --- a/install/kubernetes/github-actions-cache-server/values.yaml +++ b/install/kubernetes/github-actions-cache-server/values.yaml @@ -67,7 +67,7 @@ config: # -- Storage driver configuration # See https://gha-cache-server.falcondev.io/storage-drivers storage: - # -- Storage driver to use: "filesystem", "s3", or "gcs" + # -- Storage driver to use: "filesystem", "s3", "gcs", or "azblob" driver: filesystem # -- Filesystem storage driver settings @@ -102,6 +102,17 @@ config: # -- Custom GCS API endpoint # endpoint: '' + # -- Azure Blob Storage driver settings + azblob: + # -- Azure Storage account name (required when driver is "azblob") + # account: '' + # -- Azure Blob container name (required when driver is "azblob") + # container: '' + # -- Azure Storage connection string. Can also be provided via existingSecret. + # connectionString: '' + # -- Custom Azure Blob Storage endpoint URL (optional, defaults URL created using account name) + # endpoint: '' + # -- Database driver configuration # See https://gha-cache-server.falcondev.io/database-drivers db: diff --git a/lib/schemas.ts b/lib/schemas.ts index bca21c6..f68a09a 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -20,6 +20,13 @@ export const envStorageDriverSchema = type.or( 'STORAGE_GCS_SERVICE_ACCOUNT_KEY?': 'string', 'STORAGE_GCS_ENDPOINT?': 'string.url', }, + { + 'STORAGE_DRIVER': type.unit('azblob'), + 'STORAGE_AZBLOB_ACCOUNT': 'string', + 'STORAGE_AZBLOB_CONTAINER': 'string', + 'STORAGE_AZBLOB_CONNECTION_STRING?': 'string', + 'STORAGE_AZBLOB_ENDPOINT?': 'string.url', + }, ) export const envDbDriverSchema = type.or( type.or( diff --git a/lib/storage.ts b/lib/storage.ts index c31bd21..6aca486 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -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,150 @@ class GcsAdapter implements StorageAdapter { .then((res) => res[0]) } } + +class AzBlobAdapter implements StorageAdapter { + static async fromEnv(env: Extract) { + 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 { + const blockBlobClient = this.containerClient.getBlockBlobClient(this.blobKey(objectName)) + const response = await blockBlobClient.download() + if (!response.readableStreamBody) throw new Error(`No stream for blob "${objectName}"`) + // Casting from NodeJS.ReadableStream to Readable + return response.readableStreamBody as Readable + } + + async uploadStream(objectName: string, stream: AsyncIterable): Promise { + 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 { + return this.containerClient.getBlobClient(this.blobKey(objectName)).exists() + } + + async deleteFolder(folderName: string): Promise { + const deleted = { objects: 0, bytes: 0 } + const blobs = this.containerClient.listBlobsFlat({ + prefix: this.blobKey(folderName), + }) + for await (const blob of blobs) { + deleted.objects++ + deleted.bytes += blob.properties.contentLength ?? 0 + await this.containerClient.deleteBlob(blob.name) + } + return deleted + } + + async clear(): Promise { + const blobs = this.containerClient.listBlobsFlat({ prefix: this.blobKey('') }) + for await (const blob of blobs) { + await this.containerClient.deleteBlob(blob.name) + } + } + + async countFilesInFolder(folderName: string): Promise { + 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 { + 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 { + const folders = new Map() + 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 { + const startsOn = new Date() + const expiresOn = new Date(expiresAt) + + const delegationKey = await this.client.getUserDelegationKey(startsOn, expiresOn) + + 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()}` + } +} diff --git a/package.json b/package.json index d4cb961..7e2f5d5 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "lint:fix": "eslint --fix --cache . && prettier --write --cache .", "type-check": "tsc --noEmit", "test:run": "DEBUG=true vitest run", - "test:matrix": "for db in sqlite postgres mysql; do for st in filesystem s3 gcs; do echo \"### $db + $st\"; VITEST_DB_DRIVER=$db VITEST_STORAGE_DRIVER=$st pnpm run test:run || exit 1; done; done" + "test:matrix": "for db in sqlite postgres mysql; do for st in filesystem s3 gcs azblob; do echo \"### $db + $st\"; VITEST_DB_DRIVER=$db VITEST_STORAGE_DRIVER=$st pnpm run test:run || exit 1; done; done" }, "changelogithub": { "extends": "gh:falcondev-it/configs/changelogithub" @@ -25,6 +25,8 @@ "@aws-sdk/client-s3": "^3.1085.0", "@aws-sdk/lib-storage": "^3.1085.0", "@aws-sdk/s3-request-presigner": "^3.1085.0", + "@azure/identity": "^4.13.1", + "@azure/storage-blob": "^12.33.0", "@google-cloud/storage": "^7.21.0", "@orpc/client": "^1.14.7", "@orpc/json-schema": "^1.14.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb8a807..2a22a0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,12 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.1085.0 version: 3.1085.0 + '@azure/identity': + specifier: ^4.13.1 + version: 4.13.1 + '@azure/storage-blob': + specifier: ^12.33.0 + version: 12.33.0 '@google-cloud/storage': specifier: ^7.21.0 version: 7.21.0 @@ -290,7 +296,7 @@ importers: version: 5.1.16 nitropack: specifier: ^2.13.4 - version: 2.13.4(@azure/storage-blob@12.33.0)(better-sqlite3@12.11.1)(mysql2@3.22.6(@types/node@26.1.1))(vite@7.3.1(@types/node@26.1.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 2.13.4(@azure/identity@4.13.1)(@azure/storage-blob@12.33.0)(better-sqlite3@12.11.1)(mysql2@3.22.6(@types/node@26.1.1))(vite@7.3.1(@types/node@26.1.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) pg: specifier: ^8.22.0 version: 8.22.0 @@ -565,10 +571,26 @@ packages: resolution: {integrity: sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==} engines: {node: '>=20.0.0'} + '@azure/identity@4.13.1': + resolution: {integrity: sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==} + engines: {node: '>=20.0.0'} + '@azure/logger@1.3.0': resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} engines: {node: '>=20.0.0'} + '@azure/msal-browser@5.17.3': + resolution: {integrity: sha512-qMabD7Xrm/UgRhs+/IVyCTZRjUl8Qb+uGsRrCkaKNWsiTwRaeyStJbChE7/ySNTNYtiWo7khfF7vUDd0wGMnLw==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.11.3': + resolution: {integrity: sha512-VeXOW+t3Rdd9XGX6lVyIg3DhtjMR1JD8ARKcsnGbJFUWwAmF3sHL7GwZc/ZjEUfHESResAonETRYCuG06OBT7A==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.4.3': + resolution: {integrity: sha512-tumCMmzrRhKmTbYQg/7OlfbrIKcKaf8Ed0Fw3suUpRT3owFYljznVgxcfHe8RycQXY9uyROGiLD1GjhpF45AwA==} + engines: {node: '>=20'} + '@azure/storage-blob@12.33.0': resolution: {integrity: sha512-2SX8oP8PyblUcAFZSg39c8Ls+tFjavM6sBeV+qpw33mRzRhI/5hrFJmJ/x0H9xx5l6ECPvgSP8uPxqTeVbHNIA==} engines: {node: '>=22.0.0'} @@ -4019,6 +4041,10 @@ packages: resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -4081,6 +4107,24 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.kebabcase@4.1.1: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} @@ -4090,6 +4134,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.snakecase@4.1.1: resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} @@ -4507,6 +4554,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -5786,6 +5837,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + wsl-utils@0.3.1: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} @@ -6224,6 +6279,22 @@ snapshots: fast-xml-parser: 5.10.0 tslib: 2.8.1 + '@azure/identity@4.13.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.24.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@azure/msal-browser': 5.17.3 + '@azure/msal-node': 5.4.3 + open: 10.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@azure/logger@1.3.0': dependencies: '@typespec/ts-http-runtime': 0.3.6 @@ -6231,6 +6302,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@azure/msal-browser@5.17.3': + dependencies: + '@azure/msal-common': 16.11.3 + + '@azure/msal-common@16.11.3': {} + + '@azure/msal-node@5.4.3': + dependencies: + '@azure/msal-common': 16.11.3 + jsonwebtoken: 9.0.3 + '@azure/storage-blob@12.33.0': dependencies: '@azure/abort-controller': 2.1.2 @@ -10135,6 +10217,19 @@ snapshots: eslint-visitor-keys: 5.0.1 semver: 7.8.5 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -10219,12 +10314,26 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.kebabcase@4.1.1: {} lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.snakecase@4.1.1: {} lodash.upperfirst@4.3.1: {} @@ -10693,7 +10802,7 @@ snapshots: natural-orderby@5.0.0: {} - nitropack@2.13.4(@azure/storage-blob@12.33.0)(better-sqlite3@12.11.1)(mysql2@3.22.6(@types/node@26.1.1))(vite@7.3.1(@types/node@26.1.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): + nitropack@2.13.4(@azure/identity@4.13.1)(@azure/storage-blob@12.33.0)(better-sqlite3@12.11.1)(mysql2@3.22.6(@types/node@26.1.1))(vite@7.3.1(@types/node@26.1.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.62.2) @@ -10760,7 +10869,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 6.3.0(esbuild@0.28.1)(rollup@4.62.2)(vite@7.3.1(@types/node@26.1.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) unplugin-utils: 0.3.2 - unstorage: 1.17.5(@azure/storage-blob@12.33.0)(db0@0.3.4(better-sqlite3@12.11.1)(mysql2@3.22.6(@types/node@26.1.1)))(ioredis@5.11.1) + unstorage: 1.17.5(@azure/identity@4.13.1)(@azure/storage-blob@12.33.0)(db0@0.3.4(better-sqlite3@12.11.1)(mysql2@3.22.6(@types/node@26.1.1)))(ioredis@5.11.1) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.1 @@ -10913,6 +11022,13 @@ snapshots: dependencies: wrappy: 1.0.2 + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -12089,7 +12205,7 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - unstorage@1.17.5(@azure/storage-blob@12.33.0)(db0@0.3.4(better-sqlite3@12.11.1)(mysql2@3.22.6(@types/node@26.1.1)))(ioredis@5.11.1): + unstorage@1.17.5(@azure/identity@4.13.1)(@azure/storage-blob@12.33.0)(db0@0.3.4(better-sqlite3@12.11.1)(mysql2@3.22.6(@types/node@26.1.1)))(ioredis@5.11.1): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -12100,6 +12216,7 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.4 optionalDependencies: + '@azure/identity': 4.13.1 '@azure/storage-blob': 12.33.0 db0: 0.3.4(better-sqlite3@12.11.1)(mysql2@3.22.6(@types/node@26.1.1)) ioredis: 5.11.1 @@ -12291,6 +12408,10 @@ snapshots: wrappy@1.0.2: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 diff --git a/tests/setup.ts b/tests/setup.ts index 18c1712..f5ba7a3 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -92,6 +92,13 @@ const TESTING_ENV_BY_STORAGE_DRIVER = { STORAGE_GCS_ENDPOINT: 'http://localhost:9000', STORAGE_GCS_SERVICE_ACCOUNT_KEY: 'tests/gcs-service-account-key.json', }, + azblob: { + STORAGE_DRIVER: 'azblob', + STORAGE_AZBLOB_ACCOUNT: 'devstoreaccount1', + STORAGE_AZBLOB_CONTAINER: 'vitest', + STORAGE_AZBLOB_CONNECTION_STRING: + 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1;', + }, } satisfies { [K in Env['STORAGE_DRIVER']]: Extract< (typeof envStorageDriverSchema)['infer'], @@ -202,6 +209,21 @@ export async function setup() { .start() }) .with('filesystem', () => undefined) + .with('azblob', async () => { + return new GenericContainer('mcr.microsoft.com/azure-storage/azurite') + .withCommand(['azurite-blob', '--blobHost', '0.0.0.0', '--skipApiVersionCheck']) + .withExposedPorts({ + container: 10_000, + host: 10_000, + }) + .withHealthCheck({ + test: ['CMD-SHELL', 'nc 127.0.0.1 10000 -z'], + interval: 1000, + retries: 30, + startPeriod: 1000, + }) + .start() + }) .exhaustive(), )