Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/aws

src/smoke-test.ts

152 lines · 4.1 KB · TypeScript
import { kody } from 'kody:runtime'
import { getCallerIdentity } from './identity.ts'
import { deleteObject, putObject } from './s3.ts'
import {
	COMMON_AWS_HOSTS,
	accessKeySetupUrl,
	resolveAccessKeySecretName,
	resolveSecretKeySecretName,
	resolveSessionTokenSecretName,
	secretKeySetupUrl,
	sessionTokenSetupUrl,
	type AwsAuthOptions,
} from './validation.ts'

function secretEntries(
	result: unknown,
): Array<{ name?: string; scope?: string }> {
	if (
		result &&
		typeof result === 'object' &&
		Array.isArray((result as { secrets?: unknown }).secrets)
	) {
		return (result as { secrets: Array<{ name?: string; scope?: string }> })
			.secrets
	}
	return Array.isArray(result) ? result : []
}

async function listUserSecretNames(): Promise<Set<string>> {
	try {
		const listed = await kody.secret_list({ scope: 'user' })
		const names = new Set<string>()
		for (const entry of secretEntries(listed)) {
			if (entry?.name && (entry.scope === 'user' || !entry.scope)) {
				names.add(entry.name)
			}
		}
		return names
	} catch {
		return new Set()
	}
}

/**
 * Local helper checks, plus a live STS identity read when both required
 * secrets exist and mounts resolve.
 *
 * Without secrets this still returns `{ ok: true, live: false }` and the
 * prefilled setup URLs so the package is forkable. It never dumps object
 * bodies or secret values.
 *
 * @example
 * import smokeTest from 'kody:@kody/aws/smoke-test'
 * const result = await smokeTest()
 */
export async function smokeTest(params: AwsAuthOptions = {}) {
	const previewPut = await putObject({
		...params,
		bucket: 'example-bucket',
		key: 'kody-aws-dry-run.txt',
		body: 'dry-run',
		dryRun: true,
	})
	const previewDelete = await deleteObject({
		...params,
		bucket: 'example-bucket',
		key: 'kody-aws-dry-run.txt',
		dryRun: true,
	})
	if (!('dryRun' in previewPut) || previewPut.dryRun !== true) {
		throw new Error('putObject dry-run self-check failed.')
	}
	if (!('dryRun' in previewDelete) || previewDelete.dryRun !== true) {
		throw new Error('deleteObject dry-run self-check failed.')
	}

	const accessKeySecret = resolveAccessKeySecretName(params)
	const secretKeySecret = resolveSecretKeySecretName(params)
	const sessionTokenSecret = resolveSessionTokenSecretName(params)
	const names = await listUserSecretNames()
	const hasAccessKey = names.has(accessKeySecret)
	const hasSecretKey = names.has(secretKeySecret)
	const setup = {
		secrets: {
			[accessKeySecret]: hasAccessKey,
			[secretKeySecret]: hasSecretKey,
			[sessionTokenSecret]: names.has(sessionTokenSecret),
		},
		nextSteps: [
			'Save the AWS access key id. Do not paste it into chat.',
			accessKeySetupUrl(accessKeySecret),
			'Save the AWS secret access key. Do not paste it into chat.',
			secretKeySetupUrl(secretKeySecret),
			'Optional temporary session token:',
			sessionTokenSetupUrl(sessionTokenSecret),
			'Approve the amazonaws.com hosts listed on those forms (add regional hosts as needed).',
		],
		hosts: [...COMMON_AWS_HOSTS],
	}

	if (!hasAccessKey || !hasSecretKey) {
		return {
			ok: true,
			live: false,
			selfCheck: { dryRunPut: true, dryRunDelete: true },
			accessKeySecret,
			secretKeySecret,
			setup,
		}
	}

	try {
		const identity = await getCallerIdentity(params)
		return {
			ok: true,
			live: true,
			selfCheck: { dryRunPut: true, dryRunDelete: true },
			accessKeySecret,
			secretKeySecret,
			identity: {
				account: identity.account,
				arn: identity.arn,
				userId: identity.userId,
				region: identity.region,
			},
			setup: {
				secrets: {
					[accessKeySecret]: true,
					[secretKeySecret]: true,
					[sessionTokenSecret]: names.has(sessionTokenSecret),
				},
				nextSteps: [],
				hosts: [...COMMON_AWS_HOSTS],
			},
		}
	} catch (error) {
		const message = error instanceof Error ? error.message : 'identity failed'
		return {
			ok: true,
			live: false,
			selfCheck: { dryRunPut: true, dryRunDelete: true },
			accessKeySecret,
			secretKeySecret,
			identityError: message.includes('not mounted')
				? 'Secrets exist but are not mounted in this static-import runtime. Fork or invoke the package runtime for a live STS read.'
				: message.slice(0, 300),
			setup,
		}
	}
}

export default smokeTest