Skip to content

Commit

Permalink
Merge pull request #95 from check-run-reporter/signed-url-upload
Browse files Browse the repository at this point in the history
signed url upload
  • Loading branch information
ianwremmel authored Mar 10, 2022
2 parents d67acbe + b3735b9 commit 4e5f403
Show file tree
Hide file tree
Showing 7 changed files with 254 additions and 51 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This is a monorepo (sort of) for Check Run Reporter's client library, CLI, and
CI integrations. Instead of mainting separate test suites (if any tests at all)
for each plugin, this repo contains the core TypeScript code as well as CI
plugin integration code. As part of the build and release process, each plugin
is push to the appopropriate repository for consumption.
is pushed to the appopropriate repository for consumption.

<!-- toc -->

Expand Down
4 changes: 2 additions & 2 deletions src/commands/split.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import util from 'util';

import axios from 'axios';

import {client} from '../lib/axios';
import {client, getRequestId} from '../lib/axios';
import {multiGlob} from '../lib/file';
import {Context} from '../lib/types';

Expand Down Expand Up @@ -83,7 +83,7 @@ export async function split(
throw err;
}

logger.error(`Request ID: ${err.response.headers['x-request-id']}`);
logger.error(`Request ID: ${getRequestId(err.response)}`);
logger.error(util.inspect(err.response.data, {depth: 2}));
}

Expand Down
39 changes: 38 additions & 1 deletion src/commands/submit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,44 @@ describe('submit()', () => {
beforeEach(() => nock.disableNetConnect());
afterEach(() => nock.enableNetConnect());

it('submits reports to check run reporter', async () => {
it('submits reports to check run reporter using multistep upload', async () => {
nock('https://api.check-run-reporter.com')
.post('/api/v1/submissions/upload')
.reply(201, {
keys: [
'ianwremmel/check-run-reporter/c6fb3d5423762aa2b3a8f63717ef6b320e5a1b5a/SOMEUUID-reports/junit/jest.xml',
],
signature:
'19c3cb9748107142317f4eb212b61d2be19a8c4b2aff9dc6117a7936b28313e5',
urls: {
'reports/junit/jest.xml': 'https://example.com/1',
},
});

nock('https://example.com').put('/1').reply(200);

nock('https://api.check-run-reporter.com')
.patch('/api/v1/submissions/upload')
.reply(202);

await submit(
{
label: 'foo',
report: ['reports/junit/**/*.xml'],
root: '/',
sha: '40923a72ddf9eefef938355fa96246607c706f6c',
token: 'FAKE TOKEN',
url: 'https://api.check-run-reporter.com/api/v1/submissions',
},
{logger}
);
});

it('submits reports to check run reporter, falling back to single step upload', async () => {
nock('https://api.check-run-reporter.com')
.post('/api/v1/submissions/upload')
.reply(404);

nock('https://api.check-run-reporter.com')
.post('/api/v1/submissions')
.reply(201);
Expand Down
80 changes: 37 additions & 43 deletions src/commands/submit.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import fs from 'fs';
import util from 'util';

import FormData from 'form-data';
import axios from 'axios';

import {multiGlob} from '../lib/file';
import {Context} from '../lib/types';
import {client} from '../lib/axios';

type Optional<T> = T | undefined;
import {Context, Optional} from '../lib/types';
// eslint-disable-next-line import/no-deprecated
import {multiStepUpload, singleStepUpload} from '../lib/upload';
import {getRequestId} from '../lib/axios';

interface SubmitArgs {
readonly label: Optional<string>;
Expand All @@ -22,44 +19,12 @@ interface SubmitArgs {
/**
* Submit report files to Check Run Reporter
*/
export async function submit(
{label, report, root, sha, token, url}: SubmitArgs,
context: Context
) {
export async function submit(input: SubmitArgs, context: Context) {
const {logger} = context;

const files = await multiGlob(report, context);

const formData = new FormData();
for (const file of files) {
formData.append('report', fs.createReadStream(file));
}

if (label) {
formData.append('label', label);
}
formData.append('root', root);
formData.append('sha', sha);

try {
logger.group('Uploading report to Check Run Reporter');
logger.info(`Label: ${label}`);
logger.info(`Root: ${root}`);
logger.info(`SHA: ${sha}`);
logger.debug(`URL: ${url}`);

const response = await client.post(url, formData, {
auth: {password: token, username: 'token'},
headers: {
...formData.getHeaders(),
},
maxContentLength: Infinity,
});

logger.info(`Request ID: ${response.headers['x-request-id']}`);
logger.info(`Status: ${response.status}`);
logger.info(`StatusText: ${response.statusText}`);
logger.info(JSON.stringify(response.data, null, 2));
await tryMultiStepUploadOrFallbackToSingle(input, context);
} catch (err) {
if (axios.isAxiosError(err)) {
if (!err.response) {
Expand All @@ -69,12 +34,41 @@ export async function submit(
throw err;
}

logger.error(`Request ID: ${err.response.headers['x-request-id']}`);
logger.error(util.inspect(err.response.data, {depth: 2}));
logger.error(`Request ID: ${getRequestId(err.response)}`);
logger.error(
`Response Headers: ${util.inspect(err.response.headers, {depth: 2})}`
);
logger.error(
`Response Body: ${util.inspect(err.response.data, {depth: 2})}`
);
logger.error(`Request URL: ${err.response.config.url}`);
}

throw err;
} finally {
logger.groupEnd();
}
}
/**
* Attempts to use multistep upload, but falls back to the legacy system if it
* gets a 404. This _should_ make things future proof so it'll get more
* efficient once the new version is released.
*/
async function tryMultiStepUploadOrFallbackToSingle(
input: SubmitArgs,
context: Context
) {
try {
return await multiStepUpload(input, context);
} catch (err) {
if (axios.isAxiosError(err)) {
// CI doesn't like safe-access here.
if (err.response && err.response.status === 404) {
// eslint-disable-next-line import/no-deprecated
return await singleStepUpload(input, context);
}
}

throw err;
}
}
21 changes: 17 additions & 4 deletions src/lib/axios.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import axiosRetry from 'axios-retry';
import axios from 'axios';
import axios, {AxiosResponse} from 'axios';
import ci from 'ci-info';

import pkg from '../../package.json';

const {version} = pkg;

const prInfo = typeof ci.isPR === 'boolean' ? `(PR: ${ci.isPR})` : '';

export const client = axios.create({
headers: {
'user-agent': `crr/${version} ${ci.name} ${prInfo}`,
'user-agent': [
`crr/${version}`,
ci.name,
typeof ci.isPR === 'boolean' ? `(PR: ${ci.isPR})` : null,
]
.filter(Boolean)
.join(' '),
},
});
axiosRetry(client, {retries: 3});

/**
* extract the request id from the response object
*/
export function getRequestId(response: AxiosResponse) {
return (
response.headers['x-request-id'] || response.headers['x-amz-request-id']
);
}
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ import {Logger} from './logger';
export interface Context {
readonly logger: Logger;
}

export type Optional<T> = T | undefined;
157 changes: 157 additions & 0 deletions src/lib/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import fs from 'fs';

import FormData from 'form-data';

import {client, getRequestId} from './axios';
import {multiGlob} from './file';
import {Context, Optional} from './types';

interface UploadArgs {
readonly label: Optional<string>;
readonly report: readonly string[];
readonly root: string;
readonly sha: string;
readonly token: string;
readonly url: string;
}

type URLs = Record<string, string>;
/**
* Uploads directly to Check Run Reporter. This is a legacy solution that no
* longer works for large submissions thanks to new backend architecture. It
* remains for compatibility reasons during the transition period, but multstep
* is the preferred method going forward.
* @deprecated use multiStepUpload instead
*/
export async function singleStepUpload(
{label, report, root, sha, token, url}: UploadArgs,
context: Context
) {
const {logger} = context;

logger.info(`Label: ${label}`);
logger.info(`Root: ${root}`);
logger.info(`SHA: ${sha}`);
logger.debug(`URL: ${url}`);

const filenames = await multiGlob(report, context);

const formData = new FormData();
for (const filename of filenames) {
formData.append('report', fs.createReadStream(filename));
}

if (label) {
formData.append('label', label);
}
formData.append('root', root);
formData.append('sha', sha);

const response = await client.post(url, formData, {
auth: {password: token, username: 'token'},
headers: {
...formData.getHeaders(),
},
maxContentLength: Infinity,
});

logger.info(`Request ID: ${getRequestId(response)}`);
logger.info(`Status: ${response.status}`);
logger.info(`StatusText: ${response.statusText}`);
logger.info(JSON.stringify(response.data, null, 2));

return response;
}

/**
* Orchestrates the multi-step upload process.
* @param args
* @param context
*/
export async function multiStepUpload(args: UploadArgs, context: Context) {
const {logger} = context;

const {label, report, root, sha, url} = args;

logger.info(`Label: ${label}`);
logger.info(`Root: ${root}`);
logger.info(`SHA: ${sha}`);
logger.debug(`URL: ${url}/upload`);

const filenames = await multiGlob(report, context);
logger.group('Requesing signed urls');
const {keys, urls, signature} = await getSignedUploadUrls(args, filenames);
logger.groupEnd();

logger.group('Uploading reports');
await uploadToSignedUrls(filenames, urls);
logger.groupEnd();

logger.group('Finalizing upload');
await finishMultistepUpload(args, keys, signature);
logger.groupEnd();
}

/** Fetches signed URLs */
export async function getSignedUploadUrls(
args: UploadArgs,
filenames: readonly string[]
): Promise<{keys: string[]; signature: string; urls: Record<string, string>}> {
const {label, root, sha, token, url} = args;

const response = await client.post(
`${url}/upload`,
{filenames, label, root, sha},
{
auth: {password: token, username: 'token'},

maxContentLength: Infinity,
}
);

return response.data;
}

/** Uploads directly to S3. */
export async function uploadToSignedUrls(
filenames: readonly string[],
urls: URLs
) {
for (const filename of filenames) {
const stream = fs.createReadStream(filename);
await client.put(urls[filename], stream, {
headers: {
'Content-Length': String((await fs.promises.stat(filename)).size),
},
});
}
}

/**
* Informs Check Run Reporter that all files have been uploaded and that
* processing may begin.
*/
export async function finishMultistepUpload(
args: UploadArgs,
keys: readonly string[],
signature: string
) {
const {label, root, sha, token, url} = args;

const response = await client.patch(
`${url}/upload`,
{
keys,
label,
root,
sha,
signature,
},
{
auth: {password: token, username: 'token'},
maxContentLength: Infinity,
}
);

return response;
}

0 comments on commit 4e5f403

Please sign in to comment.