console/packages/services/server/src/index.ts
2022-09-06 18:05:39 +02:00

324 lines
9.9 KiB
JavaScript

#!/usr/bin/env node
import 'reflect-metadata';
import {
createServer,
startMetrics,
ensureEnv,
registerShutdown,
reportReadiness,
optionalEnv,
} from '@hive/service-common';
import { createRegistry, LogFn, Logger } from '@hive/api';
import { createStorage as createPostgreSQLStorage, createConnectionString } from '@hive/storage';
import got from 'got';
import { stripIgnoredCharacters } from 'graphql';
import * as Sentry from '@sentry/node';
import { Dedupe, ExtraErrorData } from '@sentry/integrations';
import { asyncStorage } from './async-storage';
import { graphqlHandler } from './graphql-handler';
import { clickHouseReadDuration, clickHouseElapsedDuration } from './metrics';
import zod from 'zod';
const LegacySetUserIdMappingPayloadModel = zod.object({
auth0UserId: zod.string(),
superTokensUserId: zod.string(),
});
export async function main() {
Sentry.init({
serverName: 'api',
enabled: String(process.env.SENTRY_ENABLED) === '1',
environment: process.env.ENVIRONMENT,
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1,
tracesSampler() {
return 1;
},
release: process.env.RELEASE || 'local',
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
new Sentry.Integrations.ContextLines(),
new Sentry.Integrations.LinkedErrors(),
new ExtraErrorData({
depth: 2,
}),
new Dedupe(),
],
maxBreadcrumbs: 5,
defaultIntegrations: false,
autoSessionTracking: false,
});
const server = await createServer({
name: 'graphql-api',
tracing: true,
});
registerShutdown({
logger: server.log,
async onShutdown() {
await server.close();
},
});
function createErrorHandler(level: Sentry.SeverityLevel): LogFn {
return (error: any, errorLike?: any, ...args: any[]) => {
server.log.error(error, errorLike, ...args);
const errorObj = error instanceof Error ? error : errorLike instanceof Error ? errorLike : null;
if (errorObj instanceof Error) {
Sentry.captureException(errorObj, {
level,
extra: {
error,
errorLike,
rest: args,
},
});
}
};
}
function getRequestId() {
const store = asyncStorage.getStore();
return store?.requestId;
}
function wrapLogFn(fn: LogFn): LogFn {
return (msg, ...args) => {
const requestId = getRequestId();
if (requestId) {
fn(msg + ` - (requestId=${requestId})`, ...args);
} else {
fn(msg, ...args);
}
};
}
try {
const errorHandler = createErrorHandler('error');
const fatalHandler = createErrorHandler('fatal');
// eslint-disable-next-line no-inner-declarations
function createGraphQLLogger(binds: Record<string, any> = {}): Logger {
return {
error: wrapLogFn(errorHandler),
fatal: wrapLogFn(fatalHandler),
info: wrapLogFn(server.log.info.bind(server.log)),
warn: wrapLogFn(server.log.warn.bind(server.log)),
trace: wrapLogFn(server.log.trace.bind(server.log)),
debug: wrapLogFn(server.log.debug.bind(server.log)),
child(bindings) {
return createGraphQLLogger({
...binds,
...bindings,
requestId: getRequestId(),
});
},
};
}
const storage = await createPostgreSQLStorage(createConnectionString(process.env as any));
const graphqlLogger = createGraphQLLogger();
const registry = createRegistry({
tokens: {
endpoint: ensureEnv('TOKENS_ENDPOINT'),
},
billing: {
endpoint: process.env.BILLING_ENDPOINT ? ensureEnv('BILLING_ENDPOINT').replace(/\/$/g, '') : null,
},
emailsEndpoint: process.env.EMAILS_ENDPOINT ? ensureEnv('EMAILS_ENDPOINT').replace(/\/$/g, '') : undefined,
webhooks: {
endpoint: ensureEnv('WEBHOOKS_ENDPOINT').replace(/\/$/g, ''),
},
schemaService: {
endpoint: ensureEnv('SCHEMA_ENDPOINT').replace(/\/$/g, ''),
},
usageEstimationService: {
endpoint: process.env.USAGE_ESTIMATOR_ENDPOINT
? ensureEnv('USAGE_ESTIMATOR_ENDPOINT').replace(/\/$/g, '')
: null,
},
rateLimitService: {
endpoint: process.env.RATE_LIMIT_ENDPOINT ? ensureEnv('RATE_LIMIT_ENDPOINT').replace(/\/$/g, '') : null,
},
logger: graphqlLogger,
storage,
redis: {
host: ensureEnv('REDIS_HOST'),
port: ensureEnv('REDIS_PORT', 'number'),
password: ensureEnv('REDIS_PASSWORD'),
},
githubApp: {
appId: ensureEnv('GITHUB_APP_ID', 'number'),
privateKey: ensureEnv('GITHUB_APP_PRIVATE_KEY'),
},
clickHouse: {
protocol: ensureEnv('CLICKHOUSE_PROTOCOL'),
host: ensureEnv('CLICKHOUSE_HOST'),
port: ensureEnv('CLICKHOUSE_PORT', 'number'),
username: ensureEnv('CLICKHOUSE_USERNAME'),
password: ensureEnv('CLICKHOUSE_PASSWORD'),
onReadEnd(query, timings) {
clickHouseReadDuration.labels({ query }).observe(timings.totalSeconds);
clickHouseElapsedDuration.labels({ query }).observe(timings.elapsedSeconds);
},
},
cdn: {
authPrivateKey: ensureEnv('CDN_AUTH_PRIVATE_KEY'),
baseUrl: ensureEnv('CDN_BASE_URL'),
cloudflare: {
basePath: ensureEnv('CF_BASE_PATH'),
accountId: ensureEnv('CF_ACCOUNT_ID'),
authToken: ensureEnv('CF_AUTH_TOKEN'),
namespaceId: ensureEnv('CF_NAMESPACE_ID'),
},
},
encryptionSecret: ensureEnv('ENCRYPTION_SECRET'),
feedback: {
token: ensureEnv('FEEDBACK_SLACK_TOKEN'),
channel: ensureEnv('FEEDBACK_SLACK_CHANNEL'),
},
schemaConfig:
typeof process.env.WEB_APP_URL === 'string'
? {
schemaPublishLink(input) {
let url = `${process.env.WEB_APP_URL}/${input.organization.cleanId}/${input.project.cleanId}/${input.target.cleanId}`;
if (input.version) {
url += `/history/${input.version.id}`;
}
return url;
},
}
: {},
});
const graphqlPath = '/graphql';
const port = process.env.PORT || 4000;
const signature = Math.random().toString(16).substr(2);
const graphql = graphqlHandler({
graphiqlEndpoint: graphqlPath,
registry,
signature,
supertokens: {
connectionUri: ensureEnv('SUPERTOKENS_CONNECTION_URI'),
apiKey: ensureEnv('SUPERTOKENS_API_KEY'),
},
});
server.route({
method: ['GET', 'POST'],
url: graphqlPath,
handler: graphql,
});
const introspection = JSON.stringify({
query: stripIgnoredCharacters(/* GraphQL */ `
query readiness {
__schema {
queryType {
name
}
}
}
`),
operationName: 'readiness',
});
server.route({
method: ['GET', 'HEAD'],
url: '/_health',
async handler(req, res) {
res.status(200).send(); // eslint-disable-line @typescript-eslint/no-floating-promises -- false positive, FastifyReply.then returns void
},
});
server.route({
method: 'GET',
url: '/lab/:org/:project/:target',
async handler(req, res) {
res.status(200).send({ ok: true }); // eslint-disable-line @typescript-eslint/no-floating-promises -- false positive, FastifyReply.then returns void
},
});
server.route({
method: ['GET', 'HEAD'],
url: '/_readiness',
async handler(req, res) {
try {
const response = await got.post(`http://0.0.0.0:${port}${graphqlPath}`, {
method: 'POST',
body: introspection,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-signature': signature,
},
});
if (response.statusCode >= 200 && response.statusCode < 300) {
if (response.body.includes('"__schema"')) {
reportReadiness(true);
res.status(200).send(); // eslint-disable-line @typescript-eslint/no-floating-promises -- false positive, FastifyReply.then returns void
return;
}
}
console.error(response.statusCode, response.body);
} catch (error) {
console.error(error);
}
reportReadiness(false);
res.status(500).send(); // eslint-disable-line @typescript-eslint/no-floating-promises -- false positive, FastifyReply.then returns void
},
});
const authLegacyAuth0 = optionalEnv('AUTH_LEGACY_AUTH0', '0');
const authLegacyAPIKey = optionalEnv('AUTH_LEGACY_AUTH0_INTERNAL_API_KEY', '');
if (authLegacyAuth0 === '1') {
server.route({
method: 'POST',
url: '/__legacy/update_user_id_mapping',
async handler(req, reply) {
if (req.headers['x-authorization'] !== authLegacyAPIKey) {
reply.status(401).send({ error: 'Invalid update user id mapping key.', code: 'ERR_INVALID_KEY' }); // eslint-disable-line @typescript-eslint/no-floating-promises -- false positive, FastifyReply.then returns void
return;
}
const { auth0UserId, superTokensUserId } = LegacySetUserIdMappingPayloadModel.parse(req.body);
await storage.setSuperTokensUserId({
auth0UserId: auth0UserId.replace('google|', 'google-oauth2|'),
superTokensUserId,
externalUserId: auth0UserId,
});
reply.status(200).send(); // eslint-disable-line @typescript-eslint/no-floating-promises -- false positive, FastifyReply.then returns void
},
});
}
if (process.env.METRICS_ENABLED === 'true') {
await startMetrics();
}
await server.listen(port, '0.0.0.0');
} catch (error) {
server.log.fatal(error);
Sentry.captureException(error, {
level: 'fatal',
});
process.exit(1);
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});