Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion packages/cli/e2e/upload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { getRequiredEnv, run } from "./utils.js";

getRequiredEnv("ARGOS_TOKEN");

test("upload returns a full build URL", { timeout: 15_000 }, () => {
// This test uploads the full __fixtures__ directory, which includes a 10MB PNG
// stress fixture. That file is sharp-optimized, hashed, and uploaded to S3 over
// a real network connection, so a generous timeout is required to avoid flakes.
test("upload returns a full build URL", { timeout: 30_000 }, () => {
const buildName = `argos-cli-e2e-node-${process.env.NODE_VERSION}-${process.env.OS}`;
const uploadResult = run([
"upload",
Expand Down
67 changes: 61 additions & 6 deletions packages/core/src/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,58 @@ interface PresignedPostUploadInput extends UploadInput {
fields: Record<string, string>;
}

/**
* On failure, S3 responds with an XML body describing the error, e.g.:
*
* <?xml version="1.0" encoding="UTF-8"?>
* <Error>
* <Code>AccessDenied</Code>
* <Message>Request has expired</Message>
* ...
* </Error>
*
* Extract the `Code` and `Message` so the user gets the actual reason for the
* failure instead of a generic HTTP status. Returns `null` when the body
* cannot be read or does not look like an S3 error document.
*/
async function readS3ErrorMessage(response: Response): Promise<string | null> {
try {
const body = await response.text();
// Trim captures: pretty-printed XML can leave whitespace/newlines around
// the inner text. `[\s\S]` matches across lines in case the value wraps.
const code = body.match(/<Code>([\s\S]*?)<\/Code>/)?.[1]?.trim();
const message = body.match(/<Message>([\s\S]*?)<\/Message>/)?.[1]?.trim();
if (code && message) {
return `${code}: ${message}`;
}
return message || code || null;
} catch {
return null;
}
}

/**
* Build an error for a failed upload, including the S3 error details when
* available so the user can understand why the upload was rejected.
*/
async function createUploadError(
url: string,
response: Response,
): Promise<Error> {
const detail = await readS3ErrorMessage(response);
// `statusText` is often empty in Node (e.g. HTTP/2), so only append it when
// present to avoid a trailing space like "403 ".
const status = response.statusText
? `${response.status} ${response.statusText}`
: `${response.status}`;
return new Error(
`Failed to upload file to ${url}: ${status}${detail ? ` — ${detail}` : ""}`,
);
Comment on lines +48 to +60
}

/**
* Upload a file to S3 using a presigned PUT URL.
*/
export async function uploadFile(input: UploadInput): Promise<void> {
const file = await readFile(input.path);
const response = await fetch(input.url, {
Expand All @@ -22,17 +74,22 @@ export async function uploadFile(input: UploadInput): Promise<void> {
body: new Uint8Array(file),
});
if (!response.ok) {
throw new Error(
`Failed to upload file to ${input.url}: ${response.status} ${response.statusText}`,
);
throw await createUploadError(input.url, response);
}
}

/**
* Upload a file to S3 using a presigned POST. Unlike a presigned PUT, this
* sends the file as multipart form data alongside the policy `fields`
* provided by S3.
*/
export async function uploadFileWithPresignedPost(
input: PresignedPostUploadInput,
): Promise<void> {
const file = await readFile(input.path);
const formData = new FormData();
// The presigned policy fields (key, policy, signature, etc.) must be
// appended before the file part for S3 to accept the upload.
for (const [key, value] of Object.entries(input.fields)) {
formData.append(key, value);
}
Expand All @@ -48,8 +105,6 @@ export async function uploadFileWithPresignedPost(
body: formData,
});
if (!response.ok) {
throw new Error(
`Failed to upload file to ${input.url}: ${response.status} ${response.statusText}`,
);
throw await createUploadError(input.url, response);
}
}
Loading