mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-25 08:09:15 +00:00
feat(aws): DynamoDB, Polly and Textract can use the default credential chain (#4307)
`useCredentialChain: true` in `dynamo.aws`, `providers['aws-polly']` or `providers['aws-textract']` takes credentials from the default AWS chain instead of static keys, as `s3.s3Config` already does. One provider is shared and refreshes temporary credentials itself.
This commit is contained in:
1 parent
26d843778e
commit
4729d733bb
10 files changed
+170
-35
No files matched your search
@@ -215,7 +215,9 @@
|
||||
// "bootstrapTables": true,
|
||||
"path": "",
|
||||
// Credentials. NOTE: snake_case here, unlike `s3.s3Config` below.
|
||||
// For dynamodb-local, any non-empty values work.
|
||||
// For dynamodb-local, any non-empty values work. On AWS,
|
||||
// `"useCredentialChain": true` uses the default credential chain
|
||||
// instead of keys.
|
||||
"aws": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
@@ -493,6 +495,8 @@
|
||||
"defaultVoiceId": "",
|
||||
"speechToSpeechModelId": ""
|
||||
},
|
||||
// Both AWS providers also take `"useCredentialChain": true` in place
|
||||
// of keys, like `dynamo.aws`.
|
||||
"aws-polly": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
|
||||
@@ -501,6 +501,19 @@ describe('DDBClient — credentialled configuration', () => {
|
||||
).toThrow('requires both `access_key` and `secret_key`');
|
||||
});
|
||||
|
||||
it('accepts the credential chain in place of keys', async () => {
|
||||
const client = new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: { useCredentialChain: true, region: 'us-west-2' },
|
||||
endpoint: 'http://127.0.0.1:1',
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
await expect(client.recreateClient()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips table creation on a managed deployment', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const client = new DDBClient({
|
||||
|
||||
@@ -43,6 +43,10 @@ import { once } from 'node:events';
|
||||
import { Agent as httpsAgent } from 'node:https';
|
||||
import { HttpError } from '../../core/http';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import {
|
||||
awsCredentialsFrom,
|
||||
type AwsCredentials,
|
||||
} from '../../util/awsCredentials.js';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
import { Span } from '../../util/span.js';
|
||||
@@ -688,17 +692,15 @@ export class DDBClient extends PuterClient {
|
||||
}
|
||||
|
||||
#bindAwsClient() {
|
||||
const accessKeyId = this.#ddbConfig.aws?.access_key;
|
||||
const secretAccessKey = this.#ddbConfig.aws?.secret_key;
|
||||
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
const credentials = awsCredentialsFrom(this.#ddbConfig.aws);
|
||||
if (!credentials) {
|
||||
throw new Error(
|
||||
'DynamoDB aws config requires both `access_key` and `secret_key`',
|
||||
'DynamoDB aws config requires both `access_key` and `secret_key`, or `useCredentialChain`',
|
||||
);
|
||||
}
|
||||
|
||||
this.#bindClient({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
credentials,
|
||||
region: this.#ddbConfig.aws?.region || 'us-west-2',
|
||||
endpoint: this.#ddbConfig.endpoint,
|
||||
});
|
||||
@@ -720,7 +722,7 @@ export class DDBClient extends PuterClient {
|
||||
region,
|
||||
endpoint,
|
||||
}: {
|
||||
credentials: { accessKeyId: string; secretAccessKey: string };
|
||||
credentials: AwsCredentials;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
}) {
|
||||
|
||||
@@ -27,6 +27,10 @@ import { Mistral } from '@mistralai/mistralai';
|
||||
import { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
awsCredentialsFrom,
|
||||
type AwsCredentials,
|
||||
} from '../../util/awsCredentials.js';
|
||||
import { mimeFromName } from '../../util/fileSigning.js';
|
||||
import {
|
||||
assertActorHasCredits,
|
||||
@@ -293,11 +297,7 @@ export class OCRDriver extends PuterDriver {
|
||||
|
||||
// Textract state — one client per region.
|
||||
#textractClients: Record<string, TextractClient> = {};
|
||||
#awsConfig: {
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
region?: string;
|
||||
} | null = null;
|
||||
#awsConfig: { credentials: AwsCredentials; region: string } | null = null;
|
||||
/** DetectDocumentText rates per region, from AWS's public price list. */
|
||||
#textractRates = textractRates(OCR_COSTS[TEXTRACT_PAGE_USAGE_TYPE]);
|
||||
|
||||
@@ -314,16 +314,14 @@ export class OCRDriver extends PuterDriver {
|
||||
Record<string, unknown> | undefined;
|
||||
const textractAws = (textract?.aws ?? textract) as
|
||||
Record<string, unknown> | undefined;
|
||||
const textractAccessKey = textractAws?.access_key as string | undefined;
|
||||
const textractSecretKey = textractAws?.secret_key as string | undefined;
|
||||
const textractCredentials = awsCredentialsFrom(textractAws);
|
||||
const textractRegion =
|
||||
(textractAws?.region as string | undefined) ??
|
||||
(textract?.region as string | undefined) ??
|
||||
DEFAULT_TEXTRACT_REGION;
|
||||
if (textractAccessKey && textractSecretKey) {
|
||||
if (textractCredentials) {
|
||||
this.#awsConfig = {
|
||||
accessKeyId: textractAccessKey,
|
||||
secretAccessKey: textractSecretKey,
|
||||
credentials: textractCredentials,
|
||||
region: textractRegion,
|
||||
};
|
||||
// Warm the configured region so the cost report has its rate.
|
||||
@@ -460,10 +458,7 @@ export class OCRDriver extends PuterDriver {
|
||||
const cached = this.#textractClients[region];
|
||||
if (cached) return cached;
|
||||
const client = new TextractClient({
|
||||
credentials: {
|
||||
accessKeyId: this.#awsConfig!.accessKeyId!,
|
||||
secretAccessKey: this.#awsConfig!.secretAccessKey!,
|
||||
},
|
||||
credentials: this.#awsConfig!.credentials,
|
||||
region,
|
||||
});
|
||||
this.#textractClients[region] = client;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { awsCredentialsFrom } from '../../util/awsCredentials.js';
|
||||
import type { DriverStreamResult } from '../meta.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import {
|
||||
@@ -241,15 +242,13 @@ export class TTSDriver extends PuterDriver {
|
||||
const polly = providers['aws-polly'];
|
||||
const pollyAws = (polly?.aws ?? polly) as
|
||||
Record<string, unknown> | undefined;
|
||||
const pollyAccessKey = pollyAws?.access_key as string | undefined;
|
||||
const pollySecretKey = pollyAws?.secret_key as string | undefined;
|
||||
if (pollyAccessKey && pollySecretKey) {
|
||||
const pollyCredentials = awsCredentialsFrom(pollyAws);
|
||||
if (pollyCredentials) {
|
||||
register(
|
||||
'aws-polly',
|
||||
() =>
|
||||
new AWSPollyTTSProvider(m, {
|
||||
access_key: pollyAccessKey,
|
||||
secret_key: pollySecretKey,
|
||||
credentials: pollyCredentials,
|
||||
region: (pollyAws?.region ?? polly?.region) as
|
||||
string | undefined,
|
||||
}),
|
||||
|
||||
@@ -101,8 +101,7 @@ const aiMetering = () =>
|
||||
|
||||
const makeProvider = (region?: string) =>
|
||||
new AWSPollyTTSProvider(aiMetering(), {
|
||||
access_key: 'AKIA-TEST',
|
||||
secret_key: 'secret',
|
||||
credentials: { accessKeyId: 'AKIA-TEST', secretAccessKey: 'secret' },
|
||||
region,
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '@aws-sdk/client-polly';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { AwsCredentials } from '../../../../util/awsCredentials.js';
|
||||
import type { AiMeteringService } from '../../../util/aiCostFactor.js';
|
||||
import type { DriverStreamResult } from '../../../meta.js';
|
||||
import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js';
|
||||
@@ -83,8 +84,7 @@ export class AWSPollyTTSProvider implements ITTSProvider {
|
||||
constructor(
|
||||
private readonly meteringService: AiMeteringService,
|
||||
private readonly config: {
|
||||
access_key: string;
|
||||
secret_key: string;
|
||||
credentials: AwsCredentials;
|
||||
region?: string;
|
||||
},
|
||||
) {
|
||||
@@ -115,10 +115,7 @@ export class AWSPollyTTSProvider implements ITTSProvider {
|
||||
}
|
||||
|
||||
this.clients[resolvedRegion] = new PollyClient({
|
||||
credentials: {
|
||||
accessKeyId: cfg.access_key,
|
||||
secretAccessKey: cfg.secret_key,
|
||||
},
|
||||
credentials: cfg.credentials,
|
||||
region: resolvedRegion,
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface IAWSCredentials {
|
||||
access_key?: string;
|
||||
secret_key?: string;
|
||||
region?: string;
|
||||
/** Use the default AWS credential chain instead of the keys. */
|
||||
useCredentialChain?: boolean;
|
||||
}
|
||||
|
||||
export interface IDynamoConfig {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { awsCredentialsFrom } from './awsCredentials.js';
|
||||
|
||||
describe('awsCredentialsFrom', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('returns the static keys', () => {
|
||||
expect(
|
||||
awsCredentialsFrom({
|
||||
access_key: 'AKIA-TEST',
|
||||
secret_key: 'secret',
|
||||
}),
|
||||
).toEqual({ accessKeyId: 'AKIA-TEST', secretAccessKey: 'secret' });
|
||||
});
|
||||
|
||||
it('returns null without both keys or the chain', () => {
|
||||
expect(awsCredentialsFrom(undefined)).toBeNull();
|
||||
expect(awsCredentialsFrom({ access_key: 'AKIA-TEST' })).toBeNull();
|
||||
expect(
|
||||
awsCredentialsFrom({ access_key: '', secret_key: '' }),
|
||||
).toBeNull();
|
||||
expect(awsCredentialsFrom({ useCredentialChain: 'true' })).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the credential chain over keys, sharing one provider', async () => {
|
||||
vi.stubEnv('AWS_ACCESS_KEY_ID', 'AKIA-CHAIN');
|
||||
vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'chain-secret');
|
||||
|
||||
const first = awsCredentialsFrom({
|
||||
useCredentialChain: true,
|
||||
access_key: 'AKIA-TEST',
|
||||
secret_key: 'secret',
|
||||
});
|
||||
const second = awsCredentialsFrom({ useCredentialChain: true });
|
||||
|
||||
expect(typeof first).toBe('function');
|
||||
expect(second).toBe(first);
|
||||
await expect(
|
||||
(first as () => Promise<unknown>)(),
|
||||
).resolves.toMatchObject({
|
||||
accessKeyId: 'AKIA-CHAIN',
|
||||
secretAccessKey: 'chain-secret',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
|
||||
|
||||
type CredentialChain = ReturnType<typeof fromNodeProviderChain>;
|
||||
|
||||
export type AwsCredentials =
|
||||
{ accessKeyId: string; secretAccessKey: string } | CredentialChain;
|
||||
|
||||
let chain: CredentialChain | null = null;
|
||||
|
||||
/**
|
||||
* Credentials for an AWS client from a `{ access_key, secret_key,
|
||||
* useCredentialChain }` config block, or null when it has neither. The default
|
||||
* chain is one shared provider that refreshes temporary credentials itself.
|
||||
*/
|
||||
export const awsCredentialsFrom = (
|
||||
conf:
|
||||
| {
|
||||
access_key?: unknown;
|
||||
secret_key?: unknown;
|
||||
useCredentialChain?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
): AwsCredentials | null => {
|
||||
if (conf?.useCredentialChain === true) {
|
||||
chain ??= fromNodeProviderChain();
|
||||
return chain;
|
||||
}
|
||||
const accessKeyId = conf?.access_key;
|
||||
const secretAccessKey = conf?.secret_key;
|
||||
if (
|
||||
typeof accessKeyId === 'string' &&
|
||||
accessKeyId &&
|
||||
typeof secretAccessKey === 'string' &&
|
||||
secretAccessKey
|
||||
) {
|
||||
return { accessKeyId, secretAccessKey };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
Reference in new issue
Block a user