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
4 changes: 3 additions & 1 deletion packages/cli/src/lib/api/preprod-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
nullish,
number,
object,
optional,
Comment thread
NicoHinderling marked this conversation as resolved.
string,
tuple,
} from "valibot";
Expand Down Expand Up @@ -398,6 +399,7 @@ export async function getLatestBaseSnapshot(
/** Objectstore config within the snapshots upload-options response. */
const ObjectstoreUploadOptionsSchema = object({
url: string(),
usecase: optional(string(), "preprod"),
scopes: array(tuple([string(), string()])),
authToken: nullish(string()),
expirationPolicy: string(),
Expand Down Expand Up @@ -427,7 +429,7 @@ export async function fetchSnapshotsUploadOptions(
const { data } = await apiRequestToRegion(
regionUrl,
`projects/${org}/${project}/preprodartifacts/snapshots/upload-options/`,
{ schema: SnapshotsUploadOptionsSchema }
{ params: { usecase: "auto" }, schema: SnapshotsUploadOptionsSchema }
);
return data;
}
Expand Down
6 changes: 2 additions & 4 deletions packages/cli/src/lib/objectstore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@
import { customFetch } from "./custom-ca.js";
import { ApiError } from "./errors.js";

/** The Objectstore usecase snapshots are stored under. */
export const OBJECTSTORE_USECASE = "preprod";

/** Header carrying the Objectstore bearer token. */
const AUTH_HEADER = "x-os-auth";
/** Header carrying an object's expiration policy (e.g. `ttl:30d`). */
Expand All @@ -38,6 +35,7 @@ const PUT_TIMEOUT_MS = 120_000;
export type ObjectstoreConfig = {
/** Base service URL (may include a path prefix). */
url: string;
usecase: string;
/** Ordered scope pairs (e.g. `[["org","1"],["project","2"]]`). */
scopes: [string, string][];
/** Pre-signed bearer token, or null/absent for unauthenticated stores. */
Expand All @@ -59,7 +57,7 @@ function scopeSegment(scopes: [string, string][]): string {
*/
export function buildObjectUrl(config: ObjectstoreConfig, key: string): string {
const base = config.url.replace(TRAILING_SLASHES, "");
return `${base}/v1/objects/${OBJECTSTORE_USECASE}/${scopeSegment(
return `${base}/v1/objects/${config.usecase}/${scopeSegment(
config.scopes
)}/${key}`;
}
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/test/commands/snapshots/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function pngBytes(width: number, height: number): Buffer {
const UPLOAD_OPTIONS = {
objectstore: {
url: "https://os.example.com",
usecase: "preprod_snapshots",
scopes: [
["org", "1"],
["project", "2"],
Expand Down Expand Up @@ -103,7 +104,12 @@ describe("snapshots upload", () => {
return dir;
}

test("uploads images and creates a snapshot with a correct manifest", async () => {
test.each([
"preprod",
"preprod_snapshots",
])("uploads images to %s and creates a snapshot with a correct manifest", async (usecase) => {
const config = { ...UPLOAD_OPTIONS.objectstore, usecase };
uploadOptionsSpy.mockResolvedValue({ objectstore: config });
const dir = await writeShots();
const harness = createContext();
const func = await uploadCommand.loader();
Expand Down Expand Up @@ -139,6 +145,8 @@ describe("snapshots upload", () => {
)?.[1] as string;
expect(key).toMatch(/^1\/2\/[0-9a-f]{64}$/);
expect(key.endsWith(hash)).toBe(true);
expect(existsSpy).toHaveBeenCalledWith(config, key);
expect(putSpy).toHaveBeenCalledWith(config, key, expect.any(Uint8Array));
});

test("CLI width/height/content_hash override sidecar keys", async () => {
Expand Down
40 changes: 27 additions & 13 deletions packages/cli/test/lib/api/preprod-artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { safeParse } from "valibot";
import { parse, safeParse } from "valibot";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ApiError, ValidationError } from "../../../src/lib/errors.js";

Expand Down Expand Up @@ -379,23 +379,37 @@ describe("snapshots", () => {
).toBe(false);
});

test("fetchSnapshotsUploadOptions hits the upload-options endpoint", async () => {
apiRequestToRegionMock.mockResolvedValue({
data: {
objectstore: {
url: "https://os.example.com",
scopes: [["org", "1"]],
authToken: "tok",
expirationPolicy: "ttl:30d",
},
},
});
test.each([
{ usecase: "preprod_snapshots", expectedUsecase: "preprod_snapshots" },
{ usecase: "preprod", expectedUsecase: "preprod" },
{ usecase: undefined, expectedUsecase: "preprod" },
])("fetchSnapshotsUploadOptions negotiates auto and parses $usecase as $expectedUsecase", async ({
usecase,
expectedUsecase,
}) => {
apiRequestToRegionMock.mockImplementation(
async (_region, _path, { schema }) => ({
data: parse(schema, {
objectstore: {
url: "https://os.example.com",
usecase,
scopes: [["org", "1"]],
authToken: "tok",
expirationPolicy: "ttl:30d",
},
}),
})
);
const opts = await fetchSnapshotsUploadOptions("my-org", "my-project");
expect(opts.objectstore.url).toBe("https://os.example.com");
const [, endpoint] = apiRequestToRegionMock.mock.calls.at(-1) ?? [];
expect(opts.objectstore.usecase).toBe(expectedUsecase);
const [region, endpoint, options] =
apiRequestToRegionMock.mock.calls.at(-1) ?? [];
expect(region).toBe("https://us.sentry.io");
expect(endpoint).toBe(
"projects/my-org/my-project/preprodartifacts/snapshots/upload-options/"
);
expect(options.params).toEqual({ usecase: "auto" });
});

test("createPreprodSnapshot POSTs the manifest and parses the response", async () => {
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/test/lib/objectstore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {

const config: ObjectstoreConfig = {
url: "https://objectstore.example.com/",
usecase: "preprod_snapshots",
scopes: [
["org", "123"],
["project", "456"],
Expand All @@ -37,7 +38,9 @@ afterEach(() => {

describe("buildObjectUrl", () => {
test("joins usecase, scope, and key (stripping a trailing slash)", () => {
expect(buildObjectUrl(config, "123/456/abc")).toBe(
expect(
buildObjectUrl({ ...config, usecase: "preprod" }, "123/456/abc")
).toBe(
"https://objectstore.example.com/v1/objects/preprod/org=123;project=456/123/456/abc"
);
});
Expand All @@ -49,7 +52,7 @@ describe("objectExists", () => {
expect(await objectExists(config, "123/456/abc")).toBe(true);
const [url, init] = customFetchMock.mock.calls[0] ?? [];
expect(url).toContain(
"/v1/objects/preprod/org=123;project=456/123/456/abc"
"/v1/objects/preprod_snapshots/org=123;project=456/123/456/abc"
);
expect(init.method).toBe("HEAD");
expect(init.headers["x-os-auth"]).toBe("Bearer jwt-token");
Expand Down Expand Up @@ -85,7 +88,9 @@ describe("putObject", () => {
await putObject(config, "123/456/abc", body);

const [url, init] = customFetchMock.mock.calls[0] ?? [];
expect(url).toContain("/123/456/abc");
expect(url).toBe(
"https://objectstore.example.com/v1/objects/preprod_snapshots/org=123;project=456/123/456/abc"
);
expect(init.method).toBe("PUT");
expect(init.headers["x-os-auth"]).toBe("Bearer jwt-token");
expect(init.headers["x-sn-expiration"]).toBe("ttl:30d");
Expand Down
Loading