Skip to content

Commit f960e36

Browse files
authored
Merge pull request #18499 from Budibase/fix/ceph-s3-api
Fix/ceph s3 api
2 parents affe2b8 + 50df9c3 commit f960e36

4 files changed

Lines changed: 181 additions & 26 deletions

File tree

packages/backend-core/src/middleware/errorHandling.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export async function errorHandling(ctx: ParameterizedContext, next: Next) {
1414
if (status >= 400 && status < 500) {
1515
console.warn(err)
1616
} else {
17-
console.error("Got 400 response code", err)
17+
console.error("Got 5xx response code", err)
1818
}
1919

2020
let error: APIError = {

packages/backend-core/src/objectStore/objectStore.ts

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -167,35 +167,44 @@ export async function createBucketIfNotExists(
167167
})
168168
return { created: false, exists: true }
169169
} catch (err: any) {
170-
const statusCode = err.statusCode || err.$response?.statusCode
170+
const statusCode =
171+
err.statusCode ||
172+
err.$response?.statusCode ||
173+
err.$metadata?.httpStatusCode
171174
const promises: Record<string, Promise<any> | undefined> =
172175
STATE.bucketCreationPromises
173-
const doesntExist = statusCode === 404,
174-
noAccess = statusCode === 403
176+
177+
if (statusCode === 403) {
178+
throw new Error("Access denied to object store bucket." + err)
179+
}
180+
175181
if (promises[bucketName]) {
176182
await promises[bucketName]
177183
return { created: false, exists: true }
178-
} else if (doesntExist || noAccess) {
179-
if (doesntExist) {
180-
promises[bucketName] = client
181-
.createBucket({
182-
Bucket: bucketName,
183-
})
184-
.catch((err: any) => {
185-
// bucket was created in the meantime by another process
186-
if (err.Code !== "BucketAlreadyOwnedByYou") {
187-
throw err
188-
}
189-
})
190-
191-
await promises[bucketName]
192-
delete promises[bucketName]
193-
return { created: true, exists: false }
194-
} else {
195-
throw new Error("Access denied to object store bucket." + err)
196-
}
197-
} else {
198-
throw new Error("Unable to write to object store bucket.")
184+
}
185+
186+
// Attempt to create the bucket for any headBucket failure that is not an
187+
// explicit access denial. This covers 404 (not found) and non-standard
188+
// status codes returned by S3-compatible stores such as Ceph RadosGW.
189+
promises[bucketName] = client
190+
.createBucket({
191+
Bucket: bucketName,
192+
})
193+
.catch((err: any) => {
194+
// bucket was created in the meantime by another process
195+
if (
196+
err.Code !== "BucketAlreadyOwnedByYou" &&
197+
err.name !== "BucketAlreadyOwnedByYou"
198+
) {
199+
throw err
200+
}
201+
})
202+
203+
try {
204+
await promises[bucketName]
205+
return { created: true, exists: false }
206+
} finally {
207+
delete promises[bucketName]
199208
}
200209
}
201210
}
@@ -796,7 +805,10 @@ export async function objectExists(
796805
await client.headObject(params)
797806
return true
798807
} catch (err: any) {
799-
const statusCode = err.statusCode || err.$response?.statusCode
808+
const statusCode =
809+
err.statusCode ||
810+
err.$response?.statusCode ||
811+
err.$metadata?.httpStatusCode
800812
if (statusCode === 404) {
801813
return false
802814
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { createBucketIfNotExists } from "../objectStore"
2+
3+
const mockHeadBucket = jest.fn()
4+
const mockCreateBucket = jest.fn()
5+
6+
const mockClient = {
7+
headBucket: mockHeadBucket,
8+
createBucket: mockCreateBucket,
9+
} as any
10+
11+
describe("createBucketIfNotExists", () => {
12+
beforeEach(() => {
13+
jest.clearAllMocks()
14+
})
15+
16+
it("returns exists=true without creating when bucket already exists", async () => {
17+
mockHeadBucket.mockResolvedValue({})
18+
19+
const result = await createBucketIfNotExists(mockClient, "test-bucket")
20+
21+
expect(result).toEqual({ created: false, exists: true })
22+
expect(mockCreateBucket).not.toHaveBeenCalled()
23+
})
24+
25+
it("creates the bucket when headBucket returns 404 via statusCode", async () => {
26+
const error: any = new Error("Not Found")
27+
error.statusCode = 404
28+
mockHeadBucket.mockRejectedValue(error)
29+
mockCreateBucket.mockResolvedValue({})
30+
31+
const result = await createBucketIfNotExists(mockClient, "test-bucket")
32+
33+
expect(result).toEqual({ created: true, exists: false })
34+
expect(mockCreateBucket).toHaveBeenCalledWith({ Bucket: "test-bucket" })
35+
})
36+
37+
it("creates the bucket when headBucket returns 404 via $response", async () => {
38+
const error: any = new Error("Not Found")
39+
error.$response = { statusCode: 404 }
40+
mockHeadBucket.mockRejectedValue(error)
41+
mockCreateBucket.mockResolvedValue({})
42+
43+
const result = await createBucketIfNotExists(mockClient, "test-bucket")
44+
45+
expect(result).toEqual({ created: true, exists: false })
46+
expect(mockCreateBucket).toHaveBeenCalledWith({ Bucket: "test-bucket" })
47+
})
48+
49+
it("creates the bucket when headBucket returns 404 via $metadata (SDK v3 / Ceph)", async () => {
50+
const error: any = new Error("Not Found")
51+
error.$metadata = { httpStatusCode: 404 }
52+
mockHeadBucket.mockRejectedValue(error)
53+
mockCreateBucket.mockResolvedValue({})
54+
55+
const result = await createBucketIfNotExists(mockClient, "test-bucket")
56+
57+
expect(result).toEqual({ created: true, exists: false })
58+
expect(mockCreateBucket).toHaveBeenCalledWith({ Bucket: "test-bucket" })
59+
})
60+
61+
it("attempts creation when headBucket fails with an unrecognised status code", async () => {
62+
const error: any = new Error("Bad Request")
63+
error.$metadata = { httpStatusCode: 400 }
64+
mockHeadBucket.mockRejectedValue(error)
65+
mockCreateBucket.mockResolvedValue({})
66+
67+
const result = await createBucketIfNotExists(mockClient, "test-bucket")
68+
69+
expect(result).toEqual({ created: true, exists: false })
70+
expect(mockCreateBucket).toHaveBeenCalledWith({ Bucket: "test-bucket" })
71+
})
72+
73+
it("attempts creation when headBucket fails with no status code at all", async () => {
74+
mockHeadBucket.mockRejectedValue(new Error("network error"))
75+
mockCreateBucket.mockResolvedValue({})
76+
77+
const result = await createBucketIfNotExists(mockClient, "test-bucket")
78+
79+
expect(result).toEqual({ created: true, exists: false })
80+
})
81+
82+
it("throws when headBucket returns 403", async () => {
83+
const error: any = new Error("Forbidden")
84+
error.statusCode = 403
85+
mockHeadBucket.mockRejectedValue(error)
86+
87+
await expect(
88+
createBucketIfNotExists(mockClient, "test-bucket")
89+
).rejects.toThrow("Access denied to object store bucket.")
90+
91+
expect(mockCreateBucket).not.toHaveBeenCalled()
92+
})
93+
94+
it("swallows BucketAlreadyOwnedByYou via err.Code (race condition)", async () => {
95+
const headError: any = new Error("Not Found")
96+
headError.statusCode = 404
97+
mockHeadBucket.mockRejectedValue(headError)
98+
99+
const createError: any = new Error("BucketAlreadyOwnedByYou")
100+
createError.Code = "BucketAlreadyOwnedByYou"
101+
mockCreateBucket.mockRejectedValue(createError)
102+
103+
const result = await createBucketIfNotExists(mockClient, "test-bucket")
104+
105+
expect(result).toEqual({ created: true, exists: false })
106+
})
107+
108+
it("swallows BucketAlreadyOwnedByYou via err.name (Ceph SDK v3 format)", async () => {
109+
const headError: any = new Error("Not Found")
110+
headError.$metadata = { httpStatusCode: 404 }
111+
mockHeadBucket.mockRejectedValue(headError)
112+
113+
const createError: any = new Error("BucketAlreadyOwnedByYou")
114+
createError.name = "BucketAlreadyOwnedByYou"
115+
mockCreateBucket.mockRejectedValue(createError)
116+
117+
const result = await createBucketIfNotExists(mockClient, "test-bucket")
118+
119+
expect(result).toEqual({ created: true, exists: false })
120+
})
121+
122+
it("propagates unexpected createBucket errors", async () => {
123+
const headError: any = new Error("Not Found")
124+
headError.statusCode = 404
125+
mockHeadBucket.mockRejectedValue(headError)
126+
127+
mockCreateBucket.mockRejectedValue(new Error("InternalError"))
128+
129+
await expect(
130+
createBucketIfNotExists(mockClient, "test-bucket")
131+
).rejects.toThrow("InternalError")
132+
})
133+
})

packages/backend-core/src/objectStore/tests/objectExists.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ describe("objectExists", () => {
4848
expect(result).toBe(false)
4949
})
5050

51+
it("should return false when object does not exist (404 error with $metadata)", async () => {
52+
const error: any = new Error("Not Found")
53+
error.$metadata = { httpStatusCode: 404 }
54+
mockHeadObject.mockRejectedValue(error)
55+
56+
const result = await objectExists("test-bucket", "test-key")
57+
58+
expect(result).toBe(false)
59+
})
60+
5161
it("should throw error for other errors", async () => {
5262
const error: any = new Error("Access Denied")
5363
error.statusCode = 403

0 commit comments

Comments
 (0)