-
Notifications
You must be signed in to change notification settings - Fork 117
ACM-30639 Integrate PlacementDebugServer API into placement wizard #5995
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fa34e1b
d16fbb1
c7614b9
33b846f
d8ee236
8b2ffda
5e0c142
95877f8
b63ef5f
ce366e5
d36d922
d5b6a50
a17ac25
99d9bb1
c0a17ef
4055fbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| /* Copyright Contributors to the Open Cluster Management project */ | ||
| import type { Http2ServerRequest, Http2ServerResponse, OutgoingHttpHeaders } from 'node:http2' | ||
| import { constants } from 'node:http2' | ||
| import type { RequestOptions } from 'node:https' | ||
| import { request } from 'node:https' | ||
| import { URL } from 'node:url' | ||
| import { getServiceAgent } from '../lib/agent' | ||
| import { logger } from '../lib/logger' | ||
| import { respondInternalServerError } from '../lib/respond' | ||
| import { getAuthenticatedToken } from '../lib/token' | ||
|
|
||
| const proxyHeaders = [ | ||
| constants.HTTP2_HEADER_ACCEPT, | ||
| constants.HTTP2_HEADER_ACCEPT_ENCODING, | ||
| constants.HTTP2_HEADER_CONTENT_ENCODING, | ||
| constants.HTTP2_HEADER_CONTENT_LENGTH, | ||
| constants.HTTP2_HEADER_CONTENT_TYPE, | ||
| ] | ||
|
|
||
| const defaultServiceHost = 'cluster-manager-placement.open-cluster-management-hub.svc.cluster.local' | ||
| const defaultPlacementDebugUrl = `https://${defaultServiceHost}:9443/debug/placements/` | ||
|
|
||
| const MAX_BODY_SIZE = 1024 * 1024 // 1MB | ||
|
|
||
| function collectBody(req: Http2ServerRequest): Promise<Buffer> { | ||
| return new Promise((resolve, reject) => { | ||
| const chunks: Buffer[] = [] | ||
| let size = 0 | ||
| req.on('data', (chunk: Buffer) => { | ||
| size += chunk.length | ||
| if (size > MAX_BODY_SIZE) { | ||
| req.destroy() | ||
| reject(new Error('Request body too large')) | ||
| return | ||
| } | ||
| chunks.push(chunk) | ||
| }) | ||
| req.on('end', () => resolve(Buffer.concat(chunks))) | ||
| req.on('error', reject) | ||
| }) | ||
| } | ||
|
Comment on lines
+25
to
+41
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using collectBody here. It's store-and-forward proxy rather than a streaming proxy like search.ts The pipeline approach (as seen in search.ts) doesn't work with the mock-request test infrastructure. The search.ts tests have // TODO - pipeline is not writing response comments where they can't assert on status codes. With collectBody, our tests can actually verify status codes (200, 401, 413, 500), which is why all 6 backend tests pass and assert correctly. If we switched to pipeline, we'd lose the ability to test response status codes — the same limitation search.ts has. Tradeoff: pipeline is more memory-efficient for large payloads (streams directly), while collectBody buffers the full request body (capped at 1MB). For placement debug payloads (small JSON placement specs), the 1MB buffer is fine and gives us better testability.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm OK with this for now, but I think we should explore this some more. The Ansible route uses pipeline and seems to have tests that do validate the response code. |
||
|
|
||
| export async function placementDebug(req: Http2ServerRequest, res: Http2ServerResponse): Promise<void> { | ||
| const token = await getAuthenticatedToken(req, res) | ||
| if (!token) return | ||
|
|
||
| let body: Buffer | ||
| try { | ||
| body = await collectBody(req) | ||
| } catch (err) { | ||
| if (!res.headersSent) { | ||
| res.writeHead(413, { 'content-type': 'application/json' }) | ||
| res.end(JSON.stringify({ error: 'Request body too large' })) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| const headers: OutgoingHttpHeaders = { | ||
| authorization: `Bearer ${token}`, | ||
| } | ||
| for (const header of proxyHeaders) { | ||
| if (req.headers[header]) headers[header] = req.headers[header] | ||
| } | ||
| headers['content-type'] = 'application/json' | ||
| headers['content-length'] = body.length | ||
|
|
||
| const url = new URL(process.env.PLACEMENT_DEBUG_URL || defaultPlacementDebugUrl) | ||
| headers.host = url.hostname | ||
|
|
||
| const options: RequestOptions = { | ||
| protocol: url.protocol, | ||
| hostname: url.hostname, | ||
| port: url.port, | ||
| path: url.pathname, | ||
| method: 'POST', | ||
| headers, | ||
| agent: getServiceAgent(), | ||
| } | ||
|
|
||
| const upstream = request(options, (response) => { | ||
| if (!response) return respondInternalServerError(req, res) | ||
| res.writeHead(response.statusCode ?? 500, { | ||
| 'content-type': 'application/json', | ||
| }) | ||
| response.pipe(res as unknown as NodeJS.WritableStream) | ||
| }) | ||
|
|
||
| upstream.on('error', (err) => { | ||
| logger.error({ msg: 'placement debug upstream error', error: err.message }) | ||
| if (!res.headersSent) respondInternalServerError(req, res) | ||
| }) | ||
|
|
||
| upstream.write(body) | ||
| upstream.end() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| /* Copyright Contributors to the Open Cluster Management project */ | ||
| import { request } from '../mock-request' | ||
| import nock from 'nock' | ||
|
|
||
| const upstreamHost = 'https://cluster-manager-placement.open-cluster-management-hub.svc.cluster.local:9443' | ||
|
|
||
| function nockAuth(status = 200) { | ||
| nock(process.env.CLUSTER_API_URL).get('/apis').reply(status, { status }) | ||
| } | ||
|
|
||
| describe(`placementDebug Route`, function () { | ||
| it(`proxies placement debug request to upstream service`, async function () { | ||
| nockAuth() | ||
| nock(upstreamHost).post('/debug/placements/').reply(200, { aggregatedScores: [] }) | ||
| const res = await request('POST', '/placement-debug', { placement: 'test' }) | ||
| expect(res.statusCode).toEqual(200) | ||
| }) | ||
|
|
||
| it(`handles upstream errors`, async function () { | ||
| nockAuth() | ||
| nock(upstreamHost).post('/debug/placements/').reply(500, { error: 'internal server error' }) | ||
| const res = await request('POST', '/placement-debug', { placement: 'test' }) | ||
| expect(res.statusCode).toEqual(500) | ||
| }) | ||
|
|
||
| it(`rejects unauthenticated requests`, async function () { | ||
| nockAuth(401) | ||
| const res = await request('POST', '/placement-debug', { placement: 'test' }) | ||
| expect(res.statusCode).toEqual(401) | ||
| }) | ||
|
|
||
| it(`uses dev agent when PLACEMENT_DEBUG_URL is set`, async function () { | ||
| const original = process.env.PLACEMENT_DEBUG_URL | ||
| process.env.PLACEMENT_DEBUG_URL = 'https://localhost:9443/debug/placements/' | ||
| try { | ||
| nockAuth() | ||
| nock('https://localhost:9443').post('/debug/placements/').reply(200, { aggregatedScores: [] }) | ||
| const res = await request('POST', '/placement-debug', { placement: 'test' }) | ||
| expect(res.statusCode).toEqual(200) | ||
| } finally { | ||
| if (original === undefined) { | ||
| delete process.env.PLACEMENT_DEBUG_URL | ||
| } else { | ||
| process.env.PLACEMENT_DEBUG_URL = original | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| it(`handles upstream connection errors`, async function () { | ||
| nockAuth() | ||
| nock(upstreamHost).post('/debug/placements/').replyWithError('ECONNREFUSED') | ||
| const res = await request('POST', '/placement-debug', { placement: 'test' }) | ||
| expect(res.statusCode).toEqual(500) | ||
| }) | ||
|
|
||
| it(`rejects oversized request body`, async function () { | ||
| nockAuth() | ||
| const largeBody = { data: 'x'.repeat(1024 * 1024 + 1) } | ||
| const res = await request('POST', '/placement-debug', largeBody) | ||
| expect(res.statusCode).toEqual(413) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| /* Copyright Contributors to the Open Cluster Management project */ | ||
| import { createContext, ReactNode, useCallback, useContext, useState } from 'react' | ||
|
|
||
| const FooterContentContext = createContext<ReactNode>(undefined) | ||
| FooterContentContext.displayName = 'FooterContentContext' | ||
|
|
||
| const SetFooterContentContext = createContext<(content: ReactNode) => void>(() => null) | ||
| SetFooterContentContext.displayName = 'SetFooterContentContext' | ||
|
|
||
| export const useFooterContent = () => useContext(FooterContentContext) | ||
| export const useSetFooterContent = () => useContext(SetFooterContentContext) | ||
|
|
||
| export function FooterContentProvider(props: { children: ReactNode }) { | ||
| const [footerContent, setFooterContentState] = useState<ReactNode>(undefined) | ||
| const setFooterContent = useCallback((content: ReactNode) => { | ||
| setFooterContentState(content) | ||
| }, []) | ||
|
|
||
| return ( | ||
| <SetFooterContentContext.Provider value={setFooterContent}> | ||
| <FooterContentContext.Provider value={footerContent}>{props.children}</FooterContentContext.Provider> | ||
| </SetFooterContentContext.Provider> | ||
| ) | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.