UNPKG

@mieweb/wikigdrive

Version:

Google Drive to MarkDown synchronization

447 lines (389 loc) 14.8 kB
import process from 'node:process'; import winston from 'winston'; import yaml from 'js-yaml'; import {Container, ContainerEngine} from '../../ContainerEngine.ts'; import {FileId} from '../../model/model.ts'; import {BufferWritable} from '../../utils/BufferWritable.ts'; import {UserConfigService} from '../google_folder/UserConfigService.ts'; import {GitScanner} from '../../git/GitScanner.ts'; import {FileContentService} from '../../utils/FileContentService.ts'; import {DockerContainer} from './DockerContainer.ts'; import {PodmanContainer} from './PodmanContainer.ts'; import {ActionTransform} from './ActionTransform.ts'; const __filename = import.meta.filename; export interface ActionStep { name?: string; uses?: string; run?: string; with?: {[key: string]: string}; env?: {[key: string]: string}; } export interface ActionDefinitionLegacy { on: string; 'run-name'?: string; steps: Array<ActionStep>; } export interface WorkflowJob { name: string; 'runs-on'?: string; steps: Array<ActionStep>; hide_in_menu?: boolean; } export interface WorkflowDefinition { on: {[trigger: string]: string}; jobs: { [key: string]: WorkflowJob } } export const DEFAULT_WORKFLOW: WorkflowDefinition = { on: { 'internal/sync': 'transform_all', 'transform_all': 'autocommit_render', 'transform_single': 'autocommit_render', 'internal/branch': 'commit_and_push_branch' }, jobs: { 'transform_all': { name: 'Transform All', steps: [ { uses: 'internal/transform', with: { 'selectedFileId': null } } ] }, 'transform_single': { name: 'Transform Single', steps: [ { uses: 'internal/transform', with: { 'selectedFileId': '$wgd.selectedFileId' } } ] }, 'autocommit_render': { name: 'AutoCommit and Render', steps: [ { name: 'internal/auto_commit', uses: 'internal/auto_commit', }, { name: 'internal/render_hugo', uses: 'internal/render_hugo@v1', }, { name: 'Export preview to nginx', uses: 'internal/export_preview' } ] }, 'commit_and_push_branch': { name: 'Commit and Push branch', hide_in_menu: true, steps: [ { uses: 'internal/commit_branch@v1' }, { uses: 'internal/push_branch' } ] } } // name: 'Check PR Labels' // runs-on: ubuntu-latest }; function migrateStep(step: ActionStep): ActionStep { if (step.uses === 'exec' && step.env?.EXEC) { return { name: step.name, run: step.env.EXEC }; } if (step.uses === 'auto_commit') { step.uses = 'internal/auto_commit'; } if (step.uses === 'commit_branch') { step.uses = 'internal/commit_branch'; } return step; } export function migrateLegacy(actionDefs: ActionDefinitionLegacy[]): WorkflowDefinition { const retVal: WorkflowDefinition = DEFAULT_WORKFLOW; for (const actionDef of actionDefs) { switch (actionDef.on) { case 'transform': retVal.jobs['autocommit_render'].steps = actionDef.steps.map(step => migrateStep(step)); retVal.jobs['autocommit_render'].steps.push( { name: 'Export preview to nginx', uses: 'internal/export_preview' }); break; } } for (const jobId in retVal.jobs) { const job = retVal.jobs[jobId]; job['runs-on'] = 'docker'; } return retVal; } export async function convertActionYaml(actionYaml: string): Promise<WorkflowDefinition> { if (!actionYaml) { return DEFAULT_WORKFLOW; } const yamlObj = yaml.load(actionYaml); const workflow: WorkflowDefinition = (Array.isArray(yamlObj)) ? migrateLegacy(yamlObj) : yamlObj; return workflow; } function withToEnv(map?: { [p: string]: string }) { const retVal = {}; if (map) { for (const key in map) { retVal['INPUT_' + key.replace(/ /g, '_').toUpperCase()] = map[key]; } } return retVal; } export class ActionRunnerContainer extends Container { private logger: winston.Logger; private generatedFileService: FileContentService; private userConfigService: UserConfigService; private tempFileService: FileContentService; private isErr = false; async init(engine: ContainerEngine): Promise<void> { await super.init(engine); this.logger = engine.logger.child({ filename: __filename, driveId: this.params.name, jobId: this.params.jobId }); } async mount3(fileService: FileContentService, destFileService: FileContentService, tempFileService: FileContentService): Promise<void> { this.filesService = fileService; this.generatedFileService = destFileService; this.tempFileService = tempFileService; this.userConfigService = new UserConfigService(this.filesService); await this.userConfigService.load(); } async run(driveId: FileId) { if (!process.env.ACTION_IMAGE) { this.logger.error('No env.ACTION_IMAGE'); this.isErr = true; return; } if (!process.env.VOLUME_DATA) { this.logger.error('No env.VOLUME_DATA'); this.isErr = true; return; } if (!process.env.VOLUME_PREVIEW) { this.logger.error('No env.VOLUME_PREVIEW'); this.isErr = true; return; } if (!process.env.DOMAIN) { this.logger.error('No env.DOMAIN'); this.isErr = true; return; } const config = this.userConfigService.config; const gitScanner = new GitScanner(this.logger, this.generatedFileService.getRealPath(), 'wikigdrive@wikigdrive.com'); await gitScanner.initialize(); const ownerRepo = await gitScanner.getOwnerRepo(); this.isErr = false; const workflow = await convertActionYaml(config.actions_yaml); const workflowJobId = workflow.on[this.params['trigger']] || this.params['action_id']; if (workflow.jobs[workflowJobId]) { const workflowJob = workflow.jobs[workflowJobId]; if (this.params['trigger'] === 'commit') { await gitScanner.pushToDir(this.tempFileService.getRealPath()); } const steps = workflowJob.steps; if (!Array.isArray(steps)) { throw new Error('No action steps'); } const committer = { name: this.params.user_name || 'WikiGDrive', email: this.params.user_email || 'wikigdrive@wikigdrive.com' }; const additionalEnv = this.payloadToEnv(); const writable = new BufferWritable(); const env = Object.assign({ CONFIG_TOML: '/site/tmp_dir/config.toml', GIT_AUTHOR_NAME: committer.name, GIT_AUTHOR_EMAIL: committer.email, GIT_COMMITTER_NAME: committer.name, GIT_COMMITTER_EMAIL: committer.email, DRIVE_ID: driveId, }, additionalEnv); const container = workflowJob['runs-on'] === 'podman' ? await PodmanContainer.create(this.logger, 'localhost/' + process.env.ACTION_IMAGE, env, `/${driveId}_transform`) : await DockerContainer.create(this.logger, process.env.ACTION_IMAGE, env, `/${driveId}_transform`); let lastCode = 0; const iterateSteps = async (callback: (step: ActionStep) => Promise<void>) => { for (const step of steps) { if (0 !== lastCode) { this.isErr = true; break; } if (!step.env) { step.env = {}; } step.env['OWNER_REPO'] = ownerRepo; step.env['PAYLOAD'] = this.params.payload; await callback(step); } }; const tryExecuteStep = async (step: ActionStep, callback: (step: ActionStep) => Promise<void>) => { try { await callback(step); if (lastCode > 0) { this.logger.error('err: '+new TextDecoder().decode(writable.getBuffer())); } else { this.logger.info(new TextDecoder().decode(writable.getBuffer())); } writable.clear(); } catch (err) { this.logger.error(err.stack ? err.stack : err.message); lastCode = 1; } }; try { await container.start(); const ghActionObjs = new Map(); // fetch actions await iterateSteps(async (step) => { if (step.uses && step.uses.indexOf('/') > -1 && step.uses.indexOf('@') > -1) { const [action_repo, action_version] = step.uses.split('@'); if (!step.uses?.startsWith('internal/')) { await container.exec(`git clone --depth 1 --branch ${action_version} https://github.com/${action_repo} /gh_actions/${action_repo}@${action_version}`, { ...step.env, ...withToEnv(step.with) }, writable); } const actionYaml = await container.getFile(`/gh_actions/${action_repo}@${action_version}/action.yml`); const ghActionObj = yaml.load(new TextDecoder().decode(actionYaml)); ghActionObjs.set(step.uses, { action_repo, action_version, ghActionObj }); } }); // action.yml:runs.pre await iterateSteps(async (step) => { await tryExecuteStep(step, async () => { if (step.uses && step.uses.indexOf('/') > -1 && step.uses.indexOf('@') > -1 && ghActionObjs.has(step.uses)) { const { action_repo, action_version, ghActionObj } = ghActionObjs.get(step.uses); const toRun = ghActionObj?.runs?.pre; if (toRun) { this.logger.info('Step [pre]: ' + (step.name || step.uses)); lastCode = await container.exec(`node /gh_actions/${action_repo}@${action_version}/${toRun} || cat /root/.npm/_logs/*`, { ...step.env, ...withToEnv(step.with) }, writable); } } }); }); // action.yml:runs.main await iterateSteps(async (step) => { if (step.run) { this.logger.info('Step: ' + (step.name || step.run)); await tryExecuteStep(step, async () => { if (step.run) { lastCode = await container.exec(step.run, { ...step.env, ...withToEnv(step.with) }, writable); } }); } else if (step.uses && step.uses?.startsWith('internal/') && !ghActionObjs.has(step.uses)) { this.logger.info('Step: ' + (step.name || step.uses)); switch (step.uses) { case 'internal/transform': try { const action = new ActionTransform(this.engine, this.filesService, this.generatedFileService); let selectedFileId = undefined; try { const payload = JSON.parse(this.params.payload); if (step.with?.selectedFileId === '$wgd.selectedFileId') { selectedFileId = payload.selectedFileId; } // deno-lint-ignore no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (ignore) { /* empty */ } await action.execute(driveId, this.params.jobId, selectedFileId ? [ selectedFileId ] : [] ); } catch (err) { this.logger.error(err.stack ? err.stack : err.message); lastCode = 1; } break; case 'internal/push_branch': { const additionalEnv = this.payloadToEnv(); await gitScanner.pushBranch(`wgd/${additionalEnv['BRANCH']}`, { privateKeyFile: await this.userConfigService.getDeployPrivateKeyPath() }, `wgd/${additionalEnv['BRANCH']}`); } break; case 'internal/auto_commit': { gitScanner.debug = true; await gitScanner.setSafeDirectory(); await gitScanner.autoCommit(); } break; case 'internal/export_preview': await container.export('/site/public', `${process.env.VOLUME_PREVIEW}/${driveId}`); break; } } else { await tryExecuteStep(step, async () => { if (step.uses && step.uses.indexOf('/') > -1 && step.uses.indexOf('@') > -1 && ghActionObjs.has(step.uses)) { const { action_repo, action_version, ghActionObj } = ghActionObjs.get(step.uses); const runsMain = ghActionObj?.runs?.main; if (runsMain) { this.logger.info('Step: ' + (step.name || step.uses)); lastCode = await container.exec(`node /gh_actions/${action_repo}@${action_version}/${runsMain} || cat /root/.npm/_logs/*`, { ...step.env, ...withToEnv(step.with) }, writable); } } }); } }); // action.yml:runs.post await iterateSteps(async (step) => { await tryExecuteStep(step, async () => { if (step.uses && step.uses.indexOf('/') > -1 && step.uses.indexOf('@') > -1 && ghActionObjs.has(step.uses)) { const { action_repo, action_version, ghActionObj } = ghActionObjs.get(step.uses); const toRun = ghActionObj?.runs?.post; if (toRun) { this.logger.info('Step [post]: ' + (step.name || step.uses)); lastCode = await container.exec(`node /gh_actions/${action_repo}@${action_version}/${toRun} || cat /root/.npm/_logs/*`, { ...step.env, ...withToEnv(step.with) }, writable); } } }); }); this.logger.info('Action completed'); } catch (err) { this.logger.error(err.stack ? err.stack : err.message); this.isErr = true; } finally { await container.stop(); await container.remove(); } } } // eslint-disable-next-line @typescript-eslint/no-empty-function async destroy(): Promise<void> { } private payloadToEnv() { const additionalEnv = {}; additionalEnv['REMOTE_BRANCH'] = this.userConfigService.config?.remote_branch || 'main'; if (this.params.payload && this.params.payload.startsWith('{')) { try { const payload = JSON.parse(this.params.payload); additionalEnv['BRANCH'] = payload.branch || ''; additionalEnv['MESSAGE'] = payload.message || ''; additionalEnv['FILES'] = Array.isArray(payload.filePaths) ? payload.filePaths.join(' ') : ''; } catch (err) { this.logger.error(err.stack ? err.stack : err.message); } } return additionalEnv; } public failed() { return this.isErr; } }