Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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: 2 additions & 0 deletions alchemy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@
"ai": "^4.0.0",
"arktype": "^2.0.0",
"cloudflare": "^4.0.0",
"diff": "^8.0.2",
"dofs": "^0.0.1",
"effect": "^3.0.0",
"hono": "^4.0.0",
"prettier": "^3.0.0",
"stripe": "^17.0.0",
Expand Down
28 changes: 21 additions & 7 deletions alchemy/src/aws/account-id.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts";

const sts = new STSClient({});
import { Effect } from "effect";
import { createAwsClient, type AwsError } from "./client.ts";

export type AccountId = string & {
readonly __brand: "AccountId";
};

/**
* Helper to get the current AWS account ID
* Helper to get the current AWS account ID using Effect-based API
*/
export function AccountId(): Effect.Effect<AccountId, AwsError> {
return Effect.gen(function* () {
const client = yield* createAwsClient({ service: "sts" });
const identity = yield* client.postJson<{
GetCallerIdentityResult: { Account: string };
}>("/", {
Action: "GetCallerIdentity",
Version: "2011-06-15",
});
return identity.GetCallerIdentityResult.Account as AccountId;
});
}

/**
* Helper to get the current AWS account ID as a Promise (for backwards compatibility)
*/
export async function AccountId(): Promise<AccountId> {
const identity = await sts.send(new GetCallerIdentityCommand({}));
return identity.Account! as AccountId;
export async function getAccountId(): Promise<AccountId> {
return Effect.runPromise(AccountId());
}
174 changes: 73 additions & 101 deletions alchemy/src/aws/bucket.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,6 @@
import {
CreateBucketCommand,
DeleteBucketCommand,
GetBucketAclCommand,
GetBucketLocationCommand,
GetBucketTaggingCommand,
GetBucketVersioningCommand,
HeadBucketCommand,
NoSuchBucket,
PutBucketTaggingCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { Context } from "../context.ts";
import { Resource } from "../resource.ts";
import { ignore } from "../util/ignore.ts";
import { retry } from "./retry.ts";
import { Effect } from "effect";
import { createAwsClient, AwsResourceNotFoundError } from "./client.ts";
import { EffectResource } from "./effect-resource.ts";

/**
* Properties for creating or updating an S3 bucket
Expand Down Expand Up @@ -127,108 +114,89 @@ export interface Bucket extends Resource<"s3::Bucket">, BucketProps {
* }
* });
*/
export const Bucket = Resource(
export const Bucket = EffectResource<Bucket, BucketProps>(
"s3::Bucket",
async function (this: Context<Bucket>, _id: string, props: BucketProps) {
const client = new S3Client({});
function* (_id, props) {
const client = yield* createAwsClient({ service: "s3" });

if (this.phase === "delete") {
await ignore(NoSuchBucket.name, () =>
retry(() =>
client.send(
new DeleteBucketCommand({
Bucket: props.bucketName,
}),
),
),
);
return this.destroy();
yield* client
.delete(`/${props.bucketName}`)
.pipe(Effect.catchAll(() => Effect.unit));
return null;
}
try {
// Check if bucket exists
await retry(() =>
client.send(
new HeadBucketCommand({
Bucket: props.bucketName,
}),

// Helper function to create tagging XML
const createTaggingXml = (tags: Record<string, string>) => {
const tagSet = Object.entries(tags).map(([Key, Value]) => ({
Key,
Value,
}));
return `<Tagging><TagSet>${tagSet
.map(
({ Key, Value }) =>
`<Tag><Key>${Key}</Key><Value>${Value}</Value></Tag>`,
)
.join("")}</TagSet></Tagging>`;
};

// Try to check if bucket exists and update tags if needed
const bucketExists = yield* client
.request("HEAD", `/${props.bucketName}`)
.pipe(
Effect.map(() => true),
Effect.catchSome((err) =>
err instanceof AwsResourceNotFoundError
? Effect.succeed(false)
: Effect.fail(err),
),
);

if (bucketExists) {
// Update tags if they changed and bucket exists
if (this.phase === "update" && props.tags) {
await retry(() =>
client.send(
new PutBucketTaggingCommand({
Bucket: props.bucketName,
Tagging: {
TagSet: Object.entries(props.tags!).map(([Key, Value]) => ({
Key,
Value,
})),
},
}),
),
);
const taggingXml = createTaggingXml(props.tags);
yield* client.put(`/${props.bucketName}?tagging`, taggingXml, {
"Content-Type": "application/xml",
});
}
} catch (error: any) {
if (error.name === "NotFound") {
// Create bucket if it doesn't exist
await retry(() =>
client.send(
new CreateBucketCommand({
Bucket: props.bucketName,
// Add tags during creation if specified
...(props.tags && {
Tagging: {
TagSet: Object.entries(props.tags).map(([Key, Value]) => ({
Key,
Value,
})),
},
}),
}),
),
);
} else {
throw error;
} else {
// Create bucket if it doesn't exist
yield* client.put(`/${props.bucketName}`);

// Add tags after creation if specified
if (props.tags) {
const taggingXml = createTaggingXml(props.tags);
yield* client.put(`/${props.bucketName}?tagging`, taggingXml, {
"Content-Type": "application/xml",
});
}
}

// Get bucket details
// Get bucket details in parallel
const [locationResponse, versioningResponse, aclResponse] =
await Promise.all([
retry(() =>
client.send(
new GetBucketLocationCommand({ Bucket: props.bucketName }),
),
),
retry(() =>
client.send(
new GetBucketVersioningCommand({ Bucket: props.bucketName }),
),
),
retry(() =>
client.send(new GetBucketAclCommand({ Bucket: props.bucketName })),
),
yield* Effect.all([
client.get(`/${props.bucketName}?location`),
client.get(`/${props.bucketName}?versioning`),
client.get(`/${props.bucketName}?acl`),
]);
Copy link
Owner Author

Choose a reason for hiding this comment

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

The docs suggest we might need a header?

GET /?location HTTP/1.1
Host: Bucket.s3.amazonaws.com

https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLocation.html


const region = locationResponse.LocationConstraint || "us-east-1";
const region = (locationResponse as any)?.LocationConstraint || "us-east-1";

// Get tags if they exist
// Get tags if they weren't provided
let tags = props.tags;
if (!tags) {
try {
const taggingResponse = await retry(() =>
client.send(
new GetBucketTaggingCommand({ Bucket: props.bucketName }),
),
);
tags = Object.fromEntries(
taggingResponse.TagSet?.map(({ Key, Value }) => [Key, Value]) || [],
);
} catch (error: any) {
if (error.name !== "NoSuchTagSet") {
throw error;
const taggingResponse = yield* client
.get(`/${props.bucketName}?tagging`)
.pipe(Effect.catchAll(() => Effect.succeed(null)));

Copy link
Owner Author

Choose a reason for hiding this comment

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

Why are we swallowing errors? We need to focus on how we handle errors

if (taggingResponse) {
// Parse XML response to extract tags
const tagSet = (taggingResponse as any)?.Tagging?.TagSet;
if (Array.isArray(tagSet)) {
tags = Object.fromEntries(
tagSet.map(({ Key, Value }: any) => [Key, Value]) || [],
);
}
}
}
Expand All @@ -240,8 +208,12 @@ export const Bucket = Resource(
bucketRegionalDomainName: `${props.bucketName}.s3.${region}.amazonaws.com`,
region,
hostedZoneId: getHostedZoneId(region),
versioningEnabled: versioningResponse.Status === "Enabled",
acl: aclResponse.Grants?.[0]?.Permission?.toLowerCase(),
versioningEnabled:
(versioningResponse as any)?.VersioningConfiguration?.Status ===
"Enabled",
acl: (
aclResponse as any
)?.AccessControlPolicy?.AccessControlList?.Grant?.[0]?.Permission?.toLowerCase(),
...(tags && { tags }),
Copy link
Owner Author

Choose a reason for hiding this comment

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

Why cast to any? Types should be respected

});
},
Expand Down
Loading
Loading