Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion install/kubernetes/github-actions-cache-server/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

@Jonas-Beck Jonas-Beck Aug 4, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was initially considering if STORAGE_AZBLOB_CONTAINER should be optional and default to e.g gh-actions-cache.

Currently it's using createIfNotExists() when creating the container client, so that there would always be a container available.

'STORAGE_AZBLOB_CONNECTION_STRING?': 'string',
'STORAGE_AZBLOB_ENDPOINT?': 'string.url',
},
)
export const envDbDriverSchema = type.or(
type.or(
Expand Down
154 changes: 154 additions & 0 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -1257,3 +1264,150 @@ 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}"`)
// Casting from NodeJS.ReadableStream to Readable
return response.readableStreamBody as Readable
}

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 deleteFolder(folderName: string): Promise<StorageDeletion> {
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<void> {
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<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)

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()}`
}
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
Loading
Loading