# Conflicts: # aio/content/cli/index.md # aio/content/file-not-found.md # aio/content/guide/architecture-modules.md # aio/content/guide/architecture-services.md # aio/content/guide/build.md # aio/content/guide/deployment.md # aio/content/guide/elements.md # aio/content/guide/feature-modules.md # aio/content/guide/file-structure.md # aio/content/guide/forms-overview.md # aio/content/guide/glossary.md # aio/content/guide/i18n.md # aio/content/guide/npm-packages.md # aio/content/guide/releases.md # aio/content/guide/router.md # aio/content/guide/service-worker-config.md # aio/content/guide/service-worker-intro.md # aio/content/guide/testing.md # aio/content/guide/upgrade-performance.md # aio/content/navigation.json # aio/content/tutorial/toh-pt0.md # aio/content/tutorial/toh-pt5.md # aio/content/tutorial/toh-pt6.md # aio/package.json # aio/src/app/custom-elements/api/api-list.component.ts # aio/src/app/documents/document.service.ts # aio/src/app/layout/top-menu/top-menu.component.ts # aio/src/index.html # aio/src/styles/2-modules/_api-pages.scss # aio/tools/transforms/templates/api/base.template.html # aio/tools/transforms/templates/api/lib/memberHelpers.html # aio/tools/transforms/templates/cli/cli-container.template.html # aio/tools/transforms/templates/lib/githubLinks.html # aio/yarn.lock # packages/animations/src/animation_metadata.ts # packages/common/http/src/backend.ts # packages/common/http/src/client.ts # packages/common/http/src/headers.ts # packages/common/http/src/interceptor.ts # packages/common/http/src/module.ts # packages/common/http/src/params.ts # packages/common/http/src/request.ts # packages/common/http/src/response.ts # packages/common/src/common_module.ts # packages/common/src/directives/ng_class.ts # packages/common/src/directives/ng_style.ts # packages/common/src/directives/ng_switch.ts # packages/common/src/i18n/format_date.ts # packages/common/src/pipes/number_pipe.ts # packages/core/src/change_detection/change_detection_util.ts # packages/core/src/change_detection/pipe_transform.ts # packages/core/src/di/injectable.ts # packages/core/src/linker/element_ref.ts # packages/core/src/linker/template_ref.ts # packages/core/src/metadata/di.ts # packages/core/src/metadata/directives.ts # packages/core/src/metadata/lifecycle_hooks.ts # packages/core/src/metadata/ng_module.ts # packages/core/src/render/api.ts # packages/forms/src/directives/form_interface.ts # packages/forms/src/directives/ng_form.ts # packages/forms/src/directives/ng_model.ts # packages/forms/src/directives/reactive_directives/form_control_name.ts # packages/forms/src/directives/select_control_value_accessor.ts # packages/forms/src/directives/validators.ts # packages/forms/src/form_builder.ts # packages/forms/src/form_providers.ts # packages/forms/src/model.ts # packages/forms/src/validators.ts # packages/platform-browser/src/browser.ts # packages/platform-browser/src/security/dom_sanitization_service.ts # packages/router/src/config.ts # packages/router/src/events.ts # packages/router/src/router.ts # packages/router/src/router_module.ts # packages/router/src/shared.ts
214 lines
8.6 KiB
TypeScript
214 lines
8.6 KiB
TypeScript
// Imports
|
|
import * as bodyParser from 'body-parser';
|
|
import * as express from 'express';
|
|
import * as http from 'http';
|
|
import { AddressInfo } from 'net';
|
|
import { CircleCiApi } from '../common/circle-ci-api';
|
|
import { GithubApi } from '../common/github-api';
|
|
import { GithubPullRequests } from '../common/github-pull-requests';
|
|
import { GithubTeams } from '../common/github-teams';
|
|
import { assert, assertNotMissingOrEmpty, computeShortSha, Logger } from '../common/utils';
|
|
import { BuildCreator } from './build-creator';
|
|
import { ChangedPrVisibilityEvent, CreatedBuildEvent } from './build-events';
|
|
import { BuildRetriever } from './build-retriever';
|
|
import { BuildVerifier } from './build-verifier';
|
|
import { respondWithError, throwRequestError } from './utils';
|
|
|
|
const AIO_PREVIEW_JOB = 'aio_preview';
|
|
|
|
// Interfaces - Types
|
|
export interface PreviewServerConfig {
|
|
downloadsDir: string;
|
|
downloadSizeLimit: number;
|
|
buildArtifactPath: string;
|
|
buildsDir: string;
|
|
domainName: string;
|
|
githubOrg: string;
|
|
githubRepo: string;
|
|
githubTeamSlugs: string[];
|
|
circleCiToken: string;
|
|
githubToken: string;
|
|
significantFilesPattern: string;
|
|
trustedPrLabel: string;
|
|
}
|
|
|
|
const logger = new Logger('PreviewServer');
|
|
|
|
// Classes
|
|
export class PreviewServerFactory {
|
|
// Methods - Public
|
|
public static create(cfg: PreviewServerConfig): http.Server {
|
|
assertNotMissingOrEmpty('domainName', cfg.domainName);
|
|
|
|
const circleCiApi = new CircleCiApi(cfg.githubOrg, cfg.githubRepo, cfg.circleCiToken);
|
|
const githubApi = new GithubApi(cfg.githubToken);
|
|
const prs = new GithubPullRequests(githubApi, cfg.githubOrg, cfg.githubRepo);
|
|
const teams = new GithubTeams(githubApi, cfg.githubOrg);
|
|
|
|
const buildRetriever = new BuildRetriever(circleCiApi, cfg.downloadSizeLimit, cfg.downloadsDir);
|
|
const buildVerifier = new BuildVerifier(prs, teams, cfg.githubTeamSlugs, cfg.trustedPrLabel);
|
|
const buildCreator = PreviewServerFactory.createBuildCreator(prs, cfg.buildsDir, cfg.domainName);
|
|
|
|
const middleware = PreviewServerFactory.createMiddleware(buildRetriever, buildVerifier, buildCreator, cfg);
|
|
const httpServer = http.createServer(middleware as any);
|
|
|
|
httpServer.on('listening', () => {
|
|
const info = httpServer.address() as AddressInfo;
|
|
logger.info(`Up and running (and listening on ${info.address}:${info.port})...`);
|
|
});
|
|
|
|
return httpServer;
|
|
}
|
|
|
|
public static createMiddleware(buildRetriever: BuildRetriever, buildVerifier: BuildVerifier,
|
|
buildCreator: BuildCreator, cfg: PreviewServerConfig): express.Express {
|
|
const middleware = express();
|
|
const jsonParser = bodyParser.json();
|
|
const significantFilesRe = new RegExp(cfg.significantFilesPattern);
|
|
|
|
// RESPOND TO IS-ALIVE PING
|
|
middleware.get(/^\/health-check\/?$/, (_req, res) => res.sendStatus(200));
|
|
|
|
// RESPOND TO CAN-HAVE-PUBLIC-PREVIEW CHECK
|
|
const canHavePublicPreviewRe = /^\/can-have-public-preview\/(\d+)\/?$/;
|
|
middleware.get(canHavePublicPreviewRe, async (req, res) => {
|
|
try {
|
|
const pr = +canHavePublicPreviewRe.exec(req.url)![1];
|
|
|
|
if (!await buildVerifier.getSignificantFilesChanged(pr, significantFilesRe)) {
|
|
// Cannot have preview: PR did not touch relevant files: `aio/` or `packages/` (except for spec files).
|
|
res.send({canHavePublicPreview: false, reason: 'No significant files touched.'});
|
|
logger.log(`PR:${pr} - Cannot have a public preview, because it did not touch any significant files.`);
|
|
} else if (!await buildVerifier.getPrIsTrusted(pr)) {
|
|
// Cannot have preview: PR not automatically verifiable as "trusted".
|
|
res.send({canHavePublicPreview: false, reason: 'Not automatically verifiable as "trusted".'});
|
|
logger.log(`PR:${pr} - Cannot have a public preview, because not automatically verifiable as "trusted".`);
|
|
} else {
|
|
// Can have preview.
|
|
res.send({canHavePublicPreview: true, reason: null});
|
|
logger.log(`PR:${pr} - Can have a public preview.`);
|
|
}
|
|
} catch (err) {
|
|
logger.error('Previewability check error', err);
|
|
respondWithError(res, err);
|
|
}
|
|
});
|
|
|
|
// CIRCLE_CI BUILD COMPLETE WEBHOOK
|
|
middleware.post(/^\/circle-build\/?$/, jsonParser, async (req, res) => {
|
|
try {
|
|
if (!(
|
|
req.is('json') &&
|
|
req.body &&
|
|
req.body.payload &&
|
|
req.body.payload.build_num > 0 &&
|
|
req.body.payload.build_parameters &&
|
|
req.body.payload.build_parameters.CIRCLE_JOB
|
|
)) {
|
|
throwRequestError(400, `Incorrect body content. Expected JSON`, req);
|
|
}
|
|
|
|
const job = req.body.payload.build_parameters.CIRCLE_JOB;
|
|
const buildNum = req.body.payload.build_num;
|
|
|
|
logger.log(`Build:${buildNum}, Job:${job} - processing web-hook trigger`);
|
|
|
|
if (job !== AIO_PREVIEW_JOB) {
|
|
res.sendStatus(204);
|
|
logger.log(`Build:${buildNum}, Job:${job} -`,
|
|
`Skipping preview processing because this is not the "${AIO_PREVIEW_JOB}" job.`);
|
|
return;
|
|
}
|
|
|
|
const { pr, sha, org, repo, success } = await buildRetriever.getGithubInfo(buildNum);
|
|
|
|
if (!success) {
|
|
res.sendStatus(204);
|
|
logger.log(`PR:${pr}, Build:${buildNum} - Skipping preview processing because this build did not succeed.`);
|
|
return;
|
|
}
|
|
|
|
assert(cfg.githubOrg === org,
|
|
`Invalid webhook: expected "githubOrg" property to equal "${cfg.githubOrg}" but got "${org}".`);
|
|
assert(cfg.githubRepo === repo,
|
|
`Invalid webhook: expected "githubRepo" property to equal "${cfg.githubRepo}" but got "${repo}".`);
|
|
|
|
// Do not deploy unless this PR has touched relevant files: `aio/` or `packages/` (except for spec files)
|
|
if (!await buildVerifier.getSignificantFilesChanged(pr, significantFilesRe)) {
|
|
res.sendStatus(204);
|
|
logger.log(`PR:${pr}, Build:${buildNum} - ` +
|
|
`Skipping preview processing because this PR did not touch any significant files.`);
|
|
return;
|
|
}
|
|
|
|
const artifactPath = await buildRetriever.downloadBuildArtifact(buildNum, pr, sha, cfg.buildArtifactPath);
|
|
const isPublic = await buildVerifier.getPrIsTrusted(pr);
|
|
await buildCreator.create(pr, sha, artifactPath, isPublic);
|
|
|
|
res.sendStatus(isPublic ? 201 : 202);
|
|
logger.log(`PR:${pr}, SHA:${computeShortSha(sha)}, Build:${buildNum} - ` +
|
|
`Successfully created ${isPublic ? 'public' : 'non-public'} preview.`);
|
|
} catch (err) {
|
|
logger.error('CircleCI webhook error', err);
|
|
respondWithError(res, err);
|
|
}
|
|
});
|
|
|
|
// GITHUB PR UPDATED WEBHOOK
|
|
middleware.post(/^\/pr-updated\/?$/, jsonParser, async (req, res) => {
|
|
const { action, number: prNo }: { action?: string, number?: number } = req.body;
|
|
const visMayHaveChanged = !action || (action === 'labeled') || (action === 'unlabeled');
|
|
|
|
try {
|
|
if (!visMayHaveChanged) {
|
|
res.sendStatus(200);
|
|
} else if (!prNo) {
|
|
throwRequestError(400, `Missing or empty 'number' field`, req);
|
|
} else {
|
|
const isPublic = await buildVerifier.getPrIsTrusted(prNo);
|
|
await buildCreator.updatePrVisibility(prNo, isPublic);
|
|
res.sendStatus(200);
|
|
}
|
|
} catch (err) {
|
|
logger.error('PR update hook error', err);
|
|
respondWithError(res, err);
|
|
}
|
|
});
|
|
|
|
// ALL OTHER REQUESTS
|
|
middleware.all('*', req => throwRequestError(404, 'Unknown resource', req));
|
|
middleware.use((err: any, _req: any, res: express.Response, _next: any) => {
|
|
const statusText = http.STATUS_CODES[err.status] || '???';
|
|
logger.error(`Preview server error: ${err.status} - ${statusText}:`, err.message);
|
|
respondWithError(res, err);
|
|
});
|
|
|
|
return middleware;
|
|
}
|
|
|
|
public static createBuildCreator(prs: GithubPullRequests, buildsDir: string, domainName: string): BuildCreator {
|
|
const buildCreator = new BuildCreator(buildsDir);
|
|
const postPreviewsComment = (pr: number, shas: string[]) => {
|
|
const body = shas.
|
|
map(sha => `You can preview ${sha} at https://pr${pr}-${sha}.${domainName}/.`).
|
|
join('\n');
|
|
|
|
return prs.addComment(pr, body);
|
|
};
|
|
|
|
buildCreator.on(CreatedBuildEvent.type, ({pr, sha, isPublic}: CreatedBuildEvent) => {
|
|
if (isPublic) {
|
|
postPreviewsComment(pr, [sha]);
|
|
}
|
|
});
|
|
|
|
buildCreator.on(ChangedPrVisibilityEvent.type, ({pr, shas, isPublic}: ChangedPrVisibilityEvent) => {
|
|
if (isPublic && shas.length) {
|
|
postPreviewsComment(pr, shas);
|
|
}
|
|
});
|
|
|
|
return buildCreator;
|
|
}
|
|
}
|