diff --git a/packages/cli/src/lib/api/preprod-artifacts.ts b/packages/cli/src/lib/api/preprod-artifacts.ts index 11708d03a..c6ff7c614 100644 --- a/packages/cli/src/lib/api/preprod-artifacts.ts +++ b/packages/cli/src/lib/api/preprod-artifacts.ts @@ -23,6 +23,7 @@ import { nullish, number, object, + optional, string, tuple, } from "valibot"; @@ -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(), @@ -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; } diff --git a/packages/cli/src/lib/objectstore.ts b/packages/cli/src/lib/objectstore.ts index 8ba0355f5..2c6442c0a 100644 --- a/packages/cli/src/lib/objectstore.ts +++ b/packages/cli/src/lib/objectstore.ts @@ -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`). */ @@ -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. */ @@ -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}`; } diff --git a/packages/cli/test/commands/snapshots/upload.test.ts b/packages/cli/test/commands/snapshots/upload.test.ts index 5fecc2d3d..1ffc0c5a1 100644 --- a/packages/cli/test/commands/snapshots/upload.test.ts +++ b/packages/cli/test/commands/snapshots/upload.test.ts @@ -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"], @@ -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(); @@ -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 () => { diff --git a/packages/cli/test/lib/api/preprod-artifacts.test.ts b/packages/cli/test/lib/api/preprod-artifacts.test.ts index fb9580437..128e82216 100644 --- a/packages/cli/test/lib/api/preprod-artifacts.test.ts +++ b/packages/cli/test/lib/api/preprod-artifacts.test.ts @@ -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"; @@ -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 () => { diff --git a/packages/cli/test/lib/objectstore.test.ts b/packages/cli/test/lib/objectstore.test.ts index d73077d69..b401eb783 100644 --- a/packages/cli/test/lib/objectstore.test.ts +++ b/packages/cli/test/lib/objectstore.test.ts @@ -20,6 +20,7 @@ import { const config: ObjectstoreConfig = { url: "https://objectstore.example.com/", + usecase: "preprod_snapshots", scopes: [ ["org", "123"], ["project", "456"], @@ -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" ); }); @@ -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"); @@ -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");