UNPKG

@ubiquity-os/plugin-sdk

Version:

SDK for plugin support.

523 lines (507 loc) 19.9 kB
// src/server.ts import { Value as Value2 } from "@sinclair/typebox/value"; import { Logs } from "@ubiquity-os/ubiquity-os-logger"; import { Hono } from "hono"; import { env as honoEnv } from "hono/adapter"; import { HTTPException } from "hono/http-exception"; // src/helpers/runtime-info.ts import github from "@actions/github"; import { getRuntimeKey } from "hono/adapter"; var PluginRuntimeInfo = class _PluginRuntimeInfo { static _instance = null; _env = {}; constructor(env) { if (env) { this._env = env; } } static getInstance(env) { if (!_PluginRuntimeInfo._instance) { _PluginRuntimeInfo._instance = getRuntimeKey() === "workerd" ? new CfRuntimeInfo(env) : new NodeRuntimeInfo(env); } return _PluginRuntimeInfo._instance; } }; var CfRuntimeInfo = class extends PluginRuntimeInfo { get version() { return Promise.resolve(this._env.CLOUDFLARE_VERSION_METADATA?.id ?? "CLOUDFLARE_VERSION_METADATA"); } get runUrl() { const accountId = this._env.CLOUDFLARE_ACCOUNT_ID ?? "<missing-cloudflare-account-id>"; const workerName = this._env.CLOUDFLARE_WORKER_NAME; const toTime = Date.now() + 6e4; const fromTime = Date.now() - 6e4; const timeParam = encodeURIComponent(`{"type":"absolute","to":${toTime},"from":${fromTime}}`); return `https://dash.cloudflare.com/${accountId}/workers/services/view/${workerName}/production/observability/logs?granularity=0&time=${timeParam}`; } }; var NodeRuntimeInfo = class extends PluginRuntimeInfo { get version() { return Promise.resolve(github.context.sha); } get runUrl() { return github.context.payload.repository ? `${github.context.payload.repository?.html_url}/actions/runs/${github.context.runId}` : "http://localhost"; } }; // src/util.ts import { LOG_LEVEL } from "@ubiquity-os/ubiquity-os-logger"; // src/constants.ts var KERNEL_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs96DOU+JqM8SyNXOB6u3 uBKIFiyrcST/LZTYN6y7LeJlyCuGPqSDrWCfjU9Ph5PLf9TWiNmeM8DGaOpwEFC7 U3NRxOSglo4plnQ5zRwIHHXvxyK400sQP2oISXymISuBQWjEIqkC9DybQrKwNzf+ I0JHWPqmwMIw26UvVOtXGOOWBqTkk+N2+/9f8eDIJP5QQVwwszc8s1rXOsLMlVIf wShw7GO4E2jyK8TSJKpyjV8eb1JJMDwFhPiRrtZfQJUtDf2mV/67shQww61BH2Y/ Plnalo58kWIbkqZoq1yJrL5sFb73osM5+vADTXVn79bkvea7W19nSkdMiarYt4Hq JQIDAQAB -----END PUBLIC KEY----- `; // src/util.ts function sanitizeMetadata(obj) { return JSON.stringify(obj, null, 2).replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/--/g, "&#45;&#45;"); } function getPluginOptions(options) { return { // Important to use || and not ?? to not consider empty strings kernelPublicKey: options?.kernelPublicKey || KERNEL_PUBLIC_KEY, logLevel: options?.logLevel || LOG_LEVEL.INFO, postCommentOnError: options?.postCommentOnError ?? true, settingsSchema: options?.settingsSchema, envSchema: options?.envSchema, commandSchema: options?.commandSchema, bypassSignatureVerification: options?.bypassSignatureVerification || false }; } // src/comment.ts var CommentHandler = class _CommentHandler { static HEADER_NAME = "UbiquityOS"; _lastCommentId = { reviewCommentId: null, issueCommentId: null }; async _updateIssueComment(context2, params) { if (!this._lastCommentId.issueCommentId) { throw context2.logger.error("issueCommentId is missing"); } const commentData = await context2.octokit.rest.issues.updateComment({ owner: params.owner, repo: params.repo, comment_id: this._lastCommentId.issueCommentId, body: params.body }); return { ...commentData.data, issueNumber: params.issueNumber }; } async _updateReviewComment(context2, params) { if (!this._lastCommentId.reviewCommentId) { throw context2.logger.error("reviewCommentId is missing"); } const commentData = await context2.octokit.rest.pulls.updateReviewComment({ owner: params.owner, repo: params.repo, comment_id: this._lastCommentId.reviewCommentId, body: params.body }); return { ...commentData.data, issueNumber: params.issueNumber }; } async _createNewComment(context2, params) { if (params.commentId) { const commentData2 = await context2.octokit.rest.pulls.createReplyForReviewComment({ owner: params.owner, repo: params.repo, pull_number: params.issueNumber, comment_id: params.commentId, body: params.body }); this._lastCommentId.reviewCommentId = commentData2.data.id; return { ...commentData2.data, issueNumber: params.issueNumber }; } const commentData = await context2.octokit.rest.issues.createComment({ owner: params.owner, repo: params.repo, issue_number: params.issueNumber, body: params.body }); this._lastCommentId.issueCommentId = commentData.data.id; return { ...commentData.data, issueNumber: params.issueNumber }; } _getIssueNumber(context2) { if ("issue" in context2.payload) return context2.payload.issue.number; if ("pull_request" in context2.payload) return context2.payload.pull_request.number; if ("discussion" in context2.payload) return context2.payload.discussion.number; return void 0; } _getCommentId(context2) { return "pull_request" in context2.payload && "comment" in context2.payload ? context2.payload.comment.id : void 0; } _extractIssueContext(context2) { if (!("repository" in context2.payload) || !context2.payload.repository?.owner?.login) { return null; } const issueNumber = this._getIssueNumber(context2); if (!issueNumber) return null; return { issueNumber, commentId: this._getCommentId(context2), owner: context2.payload.repository.owner.login, repo: context2.payload.repository.name }; } async _processMessage(context2, message) { if (message instanceof Error) { const metadata2 = { message: message.message, name: message.name, stack: message.stack }; return { metadata: metadata2, logMessage: context2.logger.error(message.message).logMessage }; } const metadata = message.metadata ? { ...message.metadata, message: message.metadata.message, stack: message.metadata.stack || message.metadata.error?.stack, caller: message.metadata.caller || message.metadata.error?.stack?.split("\n")[2]?.match(/at (\S+)/)?.[1] } : { ...message }; return { metadata, logMessage: message.logMessage }; } _getInstigatorName(context2) { if ("installation" in context2.payload && context2.payload.installation && "account" in context2.payload.installation && context2.payload.installation?.account?.name) { return context2.payload.installation?.account?.name; } return context2.payload.sender?.login || _CommentHandler.HEADER_NAME; } async _createMetadataContent(context2, metadata) { const jsonPretty = sanitizeMetadata(metadata); const instigatorName = this._getInstigatorName(context2); const runUrl = PluginRuntimeInfo.getInstance().runUrl; const version = await PluginRuntimeInfo.getInstance().version; const callingFnName = metadata.caller || "anonymous"; return { header: `<!-- ${_CommentHandler.HEADER_NAME} - ${callingFnName} - ${version} - @${instigatorName} - ${runUrl}`, jsonPretty }; } _formatMetadataContent(logMessage, header, jsonPretty) { const metadataVisible = ["```json", jsonPretty, "```"].join("\n"); const metadataHidden = [header, jsonPretty, "-->"].join("\n"); return logMessage?.type === "fatal" ? [metadataVisible, metadataHidden].join("\n") : metadataHidden; } async _createCommentBody(context2, message, options) { const { metadata, logMessage } = await this._processMessage(context2, message); const { header, jsonPretty } = await this._createMetadataContent(context2, metadata); const metadataContent = this._formatMetadataContent(logMessage, header, jsonPretty); return `${options.raw ? logMessage?.raw : logMessage?.diff} ${metadataContent} `; } async postComment(context2, message, options = { updateComment: true, raw: false }) { const issueContext = this._extractIssueContext(context2); if (!issueContext) { context2.logger.info("Cannot post comment: missing issue context in payload"); return null; } const body = await this._createCommentBody(context2, message, options); const { issueNumber, commentId, owner, repo } = issueContext; const params = { owner, repo, body, issueNumber }; if (options.updateComment) { if (this._lastCommentId.issueCommentId && !("pull_request" in context2.payload && "comment" in context2.payload)) { return this._updateIssueComment(context2, params); } if (this._lastCommentId.reviewCommentId && "pull_request" in context2.payload && "comment" in context2.payload) { return this._updateReviewComment(context2, params); } } return this._createNewComment(context2, { ...params, commentId }); } }; // src/error.ts import { LogReturn as LogReturn2 } from "@ubiquity-os/ubiquity-os-logger"; function transformError(context2, error) { let loggerError; if (error instanceof AggregateError) { loggerError = context2.logger.error( error.errors.map((err) => { if (err instanceof LogReturn2) { return err.logMessage.raw; } else if (err instanceof Error) { return err.message; } else { return err; } }).join("\n\n"), { error } ); } else if (error instanceof Error || error instanceof LogReturn2) { loggerError = error; } else { loggerError = context2.logger.error(String(error)); } return loggerError; } // src/octokit.ts import { Octokit } from "@octokit/core"; import { paginateRest } from "@octokit/plugin-paginate-rest"; import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods"; import { retry } from "@octokit/plugin-retry"; import { throttling } from "@octokit/plugin-throttling"; import { paginateGraphQL } from "@octokit/plugin-paginate-graphql"; var defaultOptions = { throttle: { onAbuseLimit: (retryAfter, options, octokit) => { octokit.log.warn(`Abuse limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`); return true; }, onRateLimit: (retryAfter, options, octokit) => { octokit.log.warn(`Rate limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`); return true; }, onSecondaryRateLimit: (retryAfter, options, octokit) => { octokit.log.warn(`Secondary rate limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`); return true; } } }; var customOctokit = Octokit.plugin(throttling, retry, paginateRest, restEndpointMethods, paginateGraphQL).defaults((instanceOptions) => { return { ...defaultOptions, ...instanceOptions }; }); // src/signature.ts async function verifySignature(publicKeyPem, inputs, signature) { try { const inputsOrdered = { stateId: inputs.stateId, eventName: inputs.eventName, eventPayload: inputs.eventPayload, settings: inputs.settings, authToken: inputs.authToken, ref: inputs.ref, command: inputs.command }; const pemContents = publicKeyPem.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").trim(); const binaryDer = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0)); const publicKey = await crypto.subtle.importKey( "spki", binaryDer, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"] ); const signatureArray = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)); const dataArray = new TextEncoder().encode(JSON.stringify(inputsOrdered)); return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, signatureArray, dataArray); } catch (error) { console.error(error); return false; } } // src/types/input-schema.ts import { Type as T2 } from "@sinclair/typebox"; // src/types/command.ts import { Type as T } from "@sinclair/typebox"; var commandCallSchema = T.Union([T.Null(), T.Object({ name: T.String(), parameters: T.Unknown() })]); // src/types/util.ts import { Type } from "@sinclair/typebox"; import { Value } from "@sinclair/typebox/value"; function jsonType(type) { return Type.Transform(Type.String()).Decode((value) => { const parsed = JSON.parse(value); return Value.Decode(type, Value.Default(type, parsed)); }).Encode((value) => JSON.stringify(value)); } // src/types/input-schema.ts var inputSchema = T2.Object({ stateId: T2.String(), eventName: T2.String(), eventPayload: jsonType(T2.Record(T2.String(), T2.Any())), command: jsonType(commandCallSchema), authToken: T2.String(), settings: jsonType(T2.Record(T2.String(), T2.Any())), ref: T2.String(), signature: T2.String() }); // src/server.ts function createPlugin(handler, manifest, options) { const pluginOptions = getPluginOptions(options); const app = new Hono(); app.get("/manifest.json", (ctx) => { return ctx.json(manifest); }); app.post("/", async function appPost(ctx) { if (ctx.req.header("content-type") !== "application/json") { throw new HTTPException(400, { message: "Content-Type must be application/json" }); } const body = await ctx.req.json(); const inputSchemaErrors = [...Value2.Errors(inputSchema, body)]; if (inputSchemaErrors.length) { console.dir(inputSchemaErrors, { depth: null }); throw new HTTPException(400, { message: "Invalid body" }); } const signature = body.signature; if (!pluginOptions.bypassSignatureVerification && !await verifySignature(pluginOptions.kernelPublicKey, body, signature)) { throw new HTTPException(400, { message: "Invalid signature" }); } const inputs = Value2.Decode(inputSchema, body); let config2; if (pluginOptions.settingsSchema) { try { config2 = Value2.Decode(pluginOptions.settingsSchema, Value2.Default(pluginOptions.settingsSchema, inputs.settings)); } catch (e) { console.dir(...Value2.Errors(pluginOptions.settingsSchema, inputs.settings), { depth: null }); throw e; } } else { config2 = inputs.settings; } let env; const honoEnvironment = honoEnv(ctx); if (pluginOptions.envSchema) { try { env = Value2.Decode(pluginOptions.envSchema, Value2.Default(pluginOptions.envSchema, honoEnvironment)); } catch (e) { console.dir(...Value2.Errors(pluginOptions.envSchema, honoEnvironment), { depth: null }); throw e; } } else { env = ctx.env; } const workerName = new URL(inputs.ref).hostname.split(".")[0]; PluginRuntimeInfo.getInstance({ ...env, CLOUDFLARE_WORKER_NAME: workerName }); let command = null; if (inputs.command && pluginOptions.commandSchema) { try { command = Value2.Decode(pluginOptions.commandSchema, Value2.Default(pluginOptions.commandSchema, inputs.command)); } catch (e) { console.log(...Value2.Errors(pluginOptions.commandSchema, inputs.command), { depth: null }); throw e; } } else if (inputs.command) { command = inputs.command; } const context2 = { eventName: inputs.eventName, payload: inputs.eventPayload, command, octokit: new customOctokit({ auth: inputs.authToken }), config: config2, env, logger: new Logs(pluginOptions.logLevel), commentHandler: new CommentHandler() }; try { const result = await handler(context2); return ctx.json({ stateId: inputs.stateId, output: result ?? {} }); } catch (error) { console.error(error); const loggerError = transformError(context2, error); if (pluginOptions.postCommentOnError && loggerError) { await context2.commentHandler.postComment(context2, loggerError); } throw new HTTPException(500, { message: "Unexpected error" }); } }); return app; } // src/actions.ts import * as core from "@actions/core"; import * as github2 from "@actions/github"; import { Value as Value3 } from "@sinclair/typebox/value"; import { LogReturn as LogReturn3, Logs as Logs2 } from "@ubiquity-os/ubiquity-os-logger"; import { config } from "dotenv"; config(); async function createActionsPlugin(handler, options) { const pluginOptions = getPluginOptions(options); const pluginGithubToken = process.env.PLUGIN_GITHUB_TOKEN; if (!pluginGithubToken) { core.setFailed("Error: PLUGIN_GITHUB_TOKEN env is not set"); return; } const body = github2.context.payload.inputs; const inputSchemaErrors = [...Value3.Errors(inputSchema, body)]; if (inputSchemaErrors.length) { console.dir(inputSchemaErrors, { depth: null }); core.setFailed(`Error: Invalid inputs payload: ${inputSchemaErrors.map((o) => o.message).join(", ")}`); return; } const signature = body.signature; if (!pluginOptions.bypassSignatureVerification && !await verifySignature(pluginOptions.kernelPublicKey, body, signature)) { core.setFailed(`Error: Invalid signature`); return; } const inputs = Value3.Decode(inputSchema, body); let config2; if (pluginOptions.settingsSchema) { try { config2 = Value3.Decode(pluginOptions.settingsSchema, Value3.Default(pluginOptions.settingsSchema, inputs.settings)); } catch (e) { console.dir(...Value3.Errors(pluginOptions.settingsSchema, inputs.settings), { depth: null }); core.setFailed(`Error: Invalid settings provided.`); throw e; } } else { config2 = inputs.settings; } let env; if (pluginOptions.envSchema) { try { env = Value3.Decode(pluginOptions.envSchema, Value3.Default(pluginOptions.envSchema, process.env)); } catch (e) { console.dir(...Value3.Errors(pluginOptions.envSchema, process.env), { depth: null }); core.setFailed(`Error: Invalid environment provided.`); throw e; } } else { env = process.env; } let command = null; if (inputs.command && pluginOptions.commandSchema) { try { command = Value3.Decode(pluginOptions.commandSchema, Value3.Default(pluginOptions.commandSchema, inputs.command)); } catch (e) { console.dir(...Value3.Errors(pluginOptions.commandSchema, inputs.command), { depth: null }); throw e; } } else if (inputs.command) { command = inputs.command; } const context2 = { eventName: inputs.eventName, payload: inputs.eventPayload, command, octokit: new customOctokit({ auth: inputs.authToken }), config: config2, env, logger: new Logs2(pluginOptions.logLevel), commentHandler: new CommentHandler() }; try { const result = await handler(context2); core.setOutput("result", result); await returnDataToKernel(pluginGithubToken, inputs.stateId, result); } catch (error) { console.error(error); const loggerError = transformError(context2, error); if (loggerError instanceof LogReturn3) { core.setFailed(loggerError.logMessage.diff); } else if (loggerError instanceof Error) { core.setFailed(loggerError); } if (pluginOptions.postCommentOnError && loggerError) { await context2.commentHandler.postComment(context2, loggerError); } } } async function returnDataToKernel(repoToken, stateId, output) { const octokit = new customOctokit({ auth: repoToken }); await octokit.rest.repos.createDispatchEvent({ owner: github2.context.repo.owner, repo: github2.context.repo.repo, event_type: "return-data-to-ubiquity-os-kernel", client_payload: { state_id: stateId, output: output ? JSON.stringify(output) : null } }); } export { CommentHandler, createActionsPlugin, createPlugin };