diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6b84c93..e0f10ad 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,7 +7,7 @@ name: Docker on: push: - branches: [ "main" ] + branches: [ "main", "dev" ] tags: [ "*" ] env: diff --git a/Dockerfile b/Dockerfile index a77e5aa..f6dd3e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use the official Deno image -FROM denoland/deno:1.46.3 +FROM denoland/deno:2.0.6 # Set working directory WORKDIR /app diff --git a/deno.lock b/deno.lock index 242abd5..5a363e4 100644 --- a/deno.lock +++ b/deno.lock @@ -1,14 +1,21 @@ { - "version": "3", - "packages": { - "specifiers": { - "npm:hono": "npm:hono@4.6.10" + "version": "4", + "specifiers": { + "jsr:@b-fuze/deno-dom@*": "0.1.48", + "jsr:@libs/xml@*": "6.0.1", + "npm:hono@*": "4.6.10" + }, + "jsr": { + "@b-fuze/deno-dom@0.1.48": { + "integrity": "bf5b591aef2e9e9c59adfcbb93a9ecd45bab5b7c8263625beafa5c8f1662e7da" }, - "npm": { - "hono@4.6.10": { - "integrity": "sha512-IXXNfRAZEahFnWBhUUlqKEGF9upeE6hZoRZszvNkyAz/CYp+iVbxm3viMvStlagRJohjlBRGOQ7f4jfcV0XMGg==", - "dependencies": {} - } + "@libs/xml@6.0.1": { + "integrity": "64af4f93464c77c3e1158fb97c3657779ca554b14f38616b96cde31e22d8a309" + } + }, + "npm": { + "hono@4.6.10": { + "integrity": "sha512-IXXNfRAZEahFnWBhUUlqKEGF9upeE6hZoRZszvNkyAz/CYp+iVbxm3viMvStlagRJohjlBRGOQ7f4jfcV0XMGg==" } }, "redirects": { diff --git a/src/services/storage.ts b/src/services/storage.ts index 2550b61..142db68 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -2,6 +2,7 @@ import { ListObjectsOptions, ListObjectsResult, StorageObject } from "../types/m import { S3, type ListObjectsV2Request } from "https://deno.land/x/aws_api@v0.8.1/services/s3/mod.ts"; import { ApiFactory } from "https://deno.land/x/aws_api@v0.8.1/client/mod.ts"; import { getSignedUrl } from "https://deno.land/x/aws_s3_presign@2.2.1/mod.ts"; +import { parse as parseXml } from "jsr:@libs/xml"; interface Credentials { awsAccessKeyId: string; @@ -11,10 +12,14 @@ interface Credentials { export class StorageService { private client: S3; - private credentials: Credentials; + private credentials!: Credentials; constructor(private bucket: string, private region: string) { - this.credentials = this.resolveCredentials(); + this.initializeClient(); + } + + private async initializeClient() { + this.credentials = await this.resolveCredentials(); const factory = new ApiFactory({ region: this.region, credentials: this.credentials, @@ -23,28 +28,35 @@ export class StorageService { this.client = new S3(factory); } - private async fetchInstanceProfileCredentials(): Promise { + // Make sure any method that uses this.client waits for initialization + private async ensureInitialized() { + if (!this.client) { + await this.initializeClient(); + } + } + + private async fetchInstanceProfileCredentials(): Promise<[Credentials | null, string | null]> { try { const metadataUrl = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"; const roleName = await (await fetch(metadataUrl)).text(); const credentials = await (await fetch(`${metadataUrl}${roleName}`)).json(); - return { + return [{ awsAccessKeyId: credentials.AccessKeyId, awsSecretKey: credentials.SecretAccessKey, sessionToken: credentials.Token, - }; - } catch { - return null; + }, null]; + } catch (error) { + return [null, `Instance profile credentials failed: ${error instanceof Error ? error.message : String(error)}`]; } } - private async fetchIRSACredentials(): Promise { + private async fetchIRSACredentials(): Promise<[Credentials | null, string | null]> { const roleArn = Deno.env.get("AWS_ROLE_ARN"); const tokenFile = Deno.env.get("AWS_WEB_IDENTITY_TOKEN_FILE"); if (!roleArn || !tokenFile) { - return null; + return [null, "IRSA credentials not configured: missing AWS_ROLE_ARN or AWS_WEB_IDENTITY_TOKEN_FILE"]; } try { @@ -52,47 +64,53 @@ export class StorageService { const sts = new WebIdentityCredentials(roleArn, token); const creds = await sts.getCredentials(); - return { + return [{ awsAccessKeyId: creds.accessKeyId, awsSecretKey: creds.secretAccessKey, sessionToken: creds.sessionToken, - }; - } catch { - return null; + }, null]; + } catch (error) { + return [null, `IRSA credentials failed: ${error instanceof Error ? error.message : String(error)}`]; } } - private getStaticCredentials(): Credentials | null { + private getStaticCredentials(): [Credentials | null, string | null] { const accessKeyId = Deno.env.get("AWS_ACCESS_KEY_ID"); const secretKey = Deno.env.get("AWS_SECRET_ACCESS_KEY"); if (!accessKeyId || !secretKey) { - return null; + return [null, "Static credentials not configured: missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY"]; } - return { + return [{ awsAccessKeyId: accessKeyId, awsSecretKey: secretKey, - }; + }, null]; } private async resolveCredentials(): Promise { + const errors: string[] = []; + // Try IRSA first - const irsaCreds = await this.fetchIRSACredentials(); + const [irsaCreds, irsaError] = await this.fetchIRSACredentials(); if (irsaCreds) return irsaCreds; + if (irsaError) errors.push(irsaError); // Then try static credentials - const staticCreds = this.getStaticCredentials(); + const [staticCreds, staticError] = this.getStaticCredentials(); if (staticCreds) return staticCreds; + if (staticError) errors.push(staticError); // Finally try instance profile - const instanceCreds = await this.fetchInstanceProfileCredentials(); + const [instanceCreds, instanceError] = await this.fetchInstanceProfileCredentials(); if (instanceCreds) return instanceCreds; + if (instanceError) errors.push(instanceError); - throw new Error("No valid AWS credentials found"); + throw new Error(`No valid AWS credentials found:\n${errors.join('\n')}`); } async getSignedUrl(key: string, expiresIn = 3600): Promise { + await this.ensureInitialized(); return await getSignedUrl({ accessKeyId: this.credentials.awsAccessKeyId, secretAccessKey: this.credentials.awsSecretKey, @@ -104,6 +122,7 @@ export class StorageService { } async listObjects(options: ListObjectsOptions): Promise { + await this.ensureInitialized(); try { const params: ListObjectsV2Request = { Bucket: this.bucket, @@ -169,24 +188,54 @@ class WebIdentityCredentials { WebIdentityToken: this.token, }); - const response = await fetch(`https://sts.amazonaws.com?${params}`, { + const url = `https://sts.amazonaws.com?${params}`; + const response = await fetch(url, { method: "GET", }); + const responseText = await response.text(); + if (!response.ok) { - throw new Error(`Failed to assume role: ${await response.text()}`); + console.error('IRSA Request Failed:', { + url, + requestHeaders: { + method: "GET", + RoleArn: this.roleArn, + TokenLength: this.token.length, + }, + responseStatus: response.status, + responseHeaders: Object.fromEntries(response.headers), + responseBody: responseText + }); + throw new Error(`Failed to assume role: ${responseText}`); } - const xml = await response.text(); - const result = new DOMParser().parseFromString(xml, "text/xml"); + const result = parseXml(responseText); + const credentials = result.AssumeRoleWithWebIdentityResponse?.AssumeRoleWithWebIdentityResult?.Credentials; - const credentials = result.querySelector("Credentials"); - if (!credentials) throw new Error("No credentials in response"); + if (!credentials) { + console.error('IRSA Parsing Failed:', { + parsedXml: JSON.stringify(result, null, 2), + rawResponse: responseText + }); + throw new Error("No credentials in response. Full response: " + JSON.stringify(result, null, 2)); + } + + const { AccessKeyId, SecretAccessKey, SessionToken } = credentials; + if (!AccessKeyId || !SecretAccessKey || !SessionToken) { + console.error('IRSA Missing Fields:', { + hasAccessKeyId: !!AccessKeyId, + hasSecretAccessKey: !!SecretAccessKey, + hasSessionToken: !!SessionToken, + parsedCredentials: credentials + }); + throw new Error("Missing required credential fields in response"); + } return { - accessKeyId: credentials.querySelector("AccessKeyId")?.textContent ?? "", - secretAccessKey: credentials.querySelector("SecretAccessKey")?.textContent ?? "", - sessionToken: credentials.querySelector("SessionToken")?.textContent ?? "", + accessKeyId: AccessKeyId, + secretAccessKey: SecretAccessKey, + sessionToken: SessionToken, }; } }