feat: ensure cleanup of docker containers (#198)

Cancelled or timeouted workflow would keep the docker container running.
Closes game-ci/unity-test-runner#197

This has two parts:

Part one. The entrypoints.

`runs.post`: GitHub Action metadata allow running something after the
action (regardless of a failure, crash, timeout, ...).
https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runspost

However, it needs to be a `.js` file and it can't be configured to pass
any arguments.

There already was `index.js` used as the main entrypoint.
The build process of this file uses typescript compiler and ncc to pack
all dependencies into one .js file. And ncc has no way of generating
multiple files in one go, so the only solution would be to run ncc twice
and generate two independent files.

That would be quite unfortunate, wasting time and storage. So I rather
came up with a new entrypoint that symlinked from two locations.
And this new entrypoint understands how it was executed, so it can run
the correct behaviour. This makes it easy to add `runs.pre` if needed.

This new entrypoint is in `index.ts`. The original `index.ts` is now in
`main.ts`.

Part two.
The signals. I've tried:
* try/catch/finally around the `await Docker.run`. Catch and finally are
  not executed when process receives SIGINT. See the discussion in: https://github.com/nodejs/node/discussions/29480
* New AbortController and AbortSignal. Great concept, but the
  action.exec does not support it. So it can't be aborted.
* Doing cleanup on `process.on('exit')`. Unfortunately you can't really
  do async stuff from there, so can't really run the docker rm command
to delete the container.
* Using `process.on('SIGINT')`. For some reason that wasn't really
  executing for me. I'd not put my hand in fire for this, but I assume
because it was in the signal handler it does something special, or would
heed to be scheduled for later with `setTimeout(0)`.

Evaluating all these I came to a conclusion that it is fragile and just
relying on a `runs.post` is much better and safer.
`
pull/199/head
Michal Cichra 2022-11-03 19:14:51 +01:00 committed by GitHub
parent 5263cf0ab1
commit 698c08cf4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 293 additions and 65 deletions

View File

@ -62,4 +62,5 @@ branding:
color: 'gray-dark'
runs:
using: 'node16'
main: 'dist/index.js'
main: 'dist/main.js'
post: 'dist/post.js'

153
dist/index.js generated vendored
View File

@ -6,6 +6,54 @@ require('./sourcemap-register.js');/******/ (() => { // webpackBootstrap
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
const path_1 = __nccwpck_require__(1017);
const main_1 = __nccwpck_require__(3109);
const post_1 = __nccwpck_require__(95);
/*
* GitHub Action can provide multiple executable entrypoints (pre, main, post),
* but it is complicated process to generate multiple `.js` files with `ncc`.
* So we rather generate just one entrypoint, that is symlinked to multiple locations (main.js and post.js).
* Then when GitHub Action Runner executes it as `node path/to/main.js` and `node path/to/post.js`,
* it can read arguments it was executed with and decide which file to execute.
* The argv[0] is going to be a full path to `node` executable and
* the argv[1] is going to be the full path to the script.
* In case index.js would be marked executable and executed directly without the argv[1] it defaults to "main.js".
*/
function run([_, name = 'main.js']) {
return __awaiter(this, void 0, void 0, function* () {
const script = (0, path_1.basename)(name);
switch (script) {
case 'main.js':
yield (0, main_1.run)();
break;
case 'post.js':
yield (0, post_1.run)();
break;
default:
throw new Error(`Unknown script argument: '${script}'`);
}
});
}
run(process.argv);
/***/ }),
/***/ 3109:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
@ -35,6 +83,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
});
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.run = void 0;
const core = __importStar(__nccwpck_require__(2186));
const model_1 = __nccwpck_require__(1359);
function run() {
@ -44,10 +93,9 @@ function run() {
const { workspace, actionFolder } = model_1.Action;
const { editorVersion, customImage, projectPath, customParameters, testMode, coverageOptions, artifactsPath, useHostNetwork, sshAgent, gitPrivateToken, githubToken, checkName, chownFilesTo, } = model_1.Input.getFromUser();
const baseImage = new model_1.ImageTag({ editorVersion, customImage });
const runnerTemporaryPath = process.env.RUNNER_TEMP;
const runnerContext = model_1.Action.runnerContext();
try {
yield model_1.Docker.run(baseImage, {
actionFolder,
yield model_1.Docker.run(baseImage, Object.assign({ actionFolder,
editorVersion,
workspace,
projectPath,
@ -59,9 +107,7 @@ function run() {
sshAgent,
gitPrivateToken,
githubToken,
runnerTemporaryPath,
chownFilesTo,
});
chownFilesTo }, runnerContext));
}
finally {
yield model_1.Output.setArtifactsPath(artifactsPath);
@ -79,7 +125,7 @@ function run() {
}
});
}
run();
exports.run = run;
/***/ }),
@ -119,6 +165,15 @@ const Action = {
get workspace() {
return process.env.GITHUB_WORKSPACE;
},
runnerContext() {
var _a, _b;
const runnerTemporaryPath = (_a = process.env.RUNNER_TEMP) !== null && _a !== void 0 ? _a : process.cwd();
const githubAction = (_b = process.env.GITHUB_ACTION) !== null && _b !== void 0 ? _b : process.pid.toString();
return {
runnerTemporaryPath,
githubAction,
};
},
checkCompatibility() {
const currentPlatform = process.platform;
if (!Action.supportedPlatforms.includes(currentPlatform)) {
@ -152,7 +207,30 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
const fs_1 = __nccwpck_require__(7147);
const exec_1 = __nccwpck_require__(1514);
const path_1 = __importDefault(__nccwpck_require__(1017));
/**
* Build a path for a docker --cidfile parameter. Docker will store the the created container.
* This path is stable for the whole execution of the action, so it can be executed with the same parameters
* multiple times and get the same result.
*/
const containerIdFilePath = parameters => {
const { runnerTemporaryPath, githubAction } = parameters;
return path_1.default.join(runnerTemporaryPath, `container_${githubAction}`);
};
const Docker = {
/**
* Remove a possible leftover container created by `Docker.run`.
*/
ensureContainerRemoval(parameters) {
return __awaiter(this, void 0, void 0, function* () {
const cidfile = containerIdFilePath(parameters);
if (!(0, fs_1.existsSync)(cidfile)) {
return;
}
const container = (0, fs_1.readFileSync)(cidfile, 'ascii').trim();
yield (0, exec_1.exec)(`docker`, ['rm', '--force', '--volumes', container], { silent: true });
(0, fs_1.rmSync)(cidfile);
});
},
run(image, parameters, silent = false) {
return __awaiter(this, void 0, void 0, function* () {
let runCommand = '';
@ -177,9 +255,11 @@ const Docker = {
const githubWorkflow = path_1.default.join(runnerTemporaryPath, '_github_workflow');
if (!(0, fs_1.existsSync)(githubWorkflow))
(0, fs_1.mkdirSync)(githubWorkflow);
const cidfile = containerIdFilePath(parameters);
const testPlatforms = (testMode === 'all' ? ['playmode', 'editmode', 'COMBINE_RESULTS'] : [testMode]).join(';');
return `docker run \
--workdir /github/workspace \
--cidfile "${cidfile}" \
--rm \
--env UNITY_LICENSE \
--env UNITY_LICENSE_FILE \
@ -228,12 +308,14 @@ const Docker = {
const githubHome = path_1.default.join(runnerTemporaryPath, '_github_home');
if (!(0, fs_1.existsSync)(githubHome))
(0, fs_1.mkdirSync)(githubHome);
const cidfile = containerIdFilePath(parameters);
const githubWorkflow = path_1.default.join(runnerTemporaryPath, '_github_workflow');
if (!(0, fs_1.existsSync)(githubWorkflow))
(0, fs_1.mkdirSync)(githubWorkflow);
const testPlatforms = (testMode === 'all' ? ['playmode', 'editmode', 'COMBINE_RESULTS'] : [testMode]).join(';');
return `docker run \
--workdir /github/workspace \
--cidfile "${cidfile}" \
--rm \
--env UNITY_LICENSE \
--env UNITY_LICENSE_FILE \
@ -1098,6 +1180,63 @@ const UnityVersionParser = {
exports["default"] = UnityVersionParser;
/***/ }),
/***/ 95:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.run = void 0;
const core = __importStar(__nccwpck_require__(2186));
const action_1 = __importDefault(__nccwpck_require__(9088));
const model_1 = __nccwpck_require__(1359);
function run() {
return __awaiter(this, void 0, void 0, function* () {
try {
const parameters = action_1.default.runnerContext();
yield model_1.Docker.ensureContainerRemoval(parameters);
}
catch (error) {
core.setFailed(error.message);
}
});
}
exports.run = run;
/***/ }),
/***/ 7351:

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

1
dist/main.js vendored 120000
View File

@ -0,0 +1 @@
index.js

1
dist/post.js vendored 120000
View File

@ -0,0 +1 @@
index.js

View File

@ -8,7 +8,7 @@
"license": "MIT",
"scripts": {
"prebuild": "yarn",
"build": "tsc && ncc build lib --source-map --license licenses.txt",
"build": "tsc && ncc build lib/index.js --source-map --license licenses.txt",
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
"format": "prettier --write \"src/**/*.{js,ts}\"",
"test": "jest",

View File

@ -1,60 +1,31 @@
import * as core from '@actions/core';
import { Action, Docker, ImageTag, Input, Output, ResultsCheck } from './model';
import { basename } from 'path';
async function run() {
try {
Action.checkCompatibility();
import { run as main } from './main';
import { run as post } from './post';
const { workspace, actionFolder } = Action;
const {
editorVersion,
customImage,
projectPath,
customParameters,
testMode,
coverageOptions,
artifactsPath,
useHostNetwork,
sshAgent,
gitPrivateToken,
githubToken,
checkName,
chownFilesTo,
} = Input.getFromUser();
const baseImage = new ImageTag({ editorVersion, customImage });
const runnerTemporaryPath = process.env.RUNNER_TEMP;
/*
* GitHub Action can provide multiple executable entrypoints (pre, main, post),
* but it is complicated process to generate multiple `.js` files with `ncc`.
* So we rather generate just one entrypoint, that is symlinked to multiple locations (main.js and post.js).
* Then when GitHub Action Runner executes it as `node path/to/main.js` and `node path/to/post.js`,
* it can read arguments it was executed with and decide which file to execute.
* The argv[0] is going to be a full path to `node` executable and
* the argv[1] is going to be the full path to the script.
* In case index.js would be marked executable and executed directly without the argv[1] it defaults to "main.js".
*/
async function run([_, name = 'main.js']: string[]) {
const script = basename(name);
try {
await Docker.run(baseImage, {
actionFolder,
editorVersion,
workspace,
projectPath,
customParameters,
testMode,
coverageOptions,
artifactsPath,
useHostNetwork,
sshAgent,
gitPrivateToken,
githubToken,
runnerTemporaryPath,
chownFilesTo,
});
} finally {
await Output.setArtifactsPath(artifactsPath);
await Output.setCoveragePath('CodeCoverage');
}
if (githubToken) {
const failedTestCount = await ResultsCheck.createCheck(artifactsPath, githubToken, checkName);
if (failedTestCount >= 1) {
core.setFailed(`Test(s) Failed! Check '${checkName}' for details.`);
}
}
} catch (error: any) {
core.setFailed(error.message);
switch (script) {
case 'main.js':
await main();
break;
case 'post.js':
await post();
break;
default:
throw new Error(`Unknown script argument: '${script}'`);
}
}
run();
run(process.argv);

58
src/main.ts 100644
View File

@ -0,0 +1,58 @@
import * as core from '@actions/core';
import { Action, Docker, ImageTag, Input, Output, ResultsCheck } from './model';
export async function run() {
try {
Action.checkCompatibility();
const { workspace, actionFolder } = Action;
const {
editorVersion,
customImage,
projectPath,
customParameters,
testMode,
coverageOptions,
artifactsPath,
useHostNetwork,
sshAgent,
gitPrivateToken,
githubToken,
checkName,
chownFilesTo,
} = Input.getFromUser();
const baseImage = new ImageTag({ editorVersion, customImage });
const runnerContext = Action.runnerContext();
try {
await Docker.run(baseImage, {
actionFolder,
editorVersion,
workspace,
projectPath,
customParameters,
testMode,
coverageOptions,
artifactsPath,
useHostNetwork,
sshAgent,
gitPrivateToken,
githubToken,
chownFilesTo,
...runnerContext,
});
} finally {
await Output.setArtifactsPath(artifactsPath);
await Output.setCoveragePath('CodeCoverage');
}
if (githubToken) {
const failedTestCount = await ResultsCheck.createCheck(artifactsPath, githubToken, checkName);
if (failedTestCount >= 1) {
core.setFailed(`Test(s) Failed! Check '${checkName}' for details.`);
}
}
} catch (error: any) {
core.setFailed(error.message);
}
}

View File

@ -1,5 +1,10 @@
import path from 'path';
export interface RunnerContext {
runnerTemporaryPath: string;
githubAction: string;
}
const Action = {
get supportedPlatforms() {
return ['linux', 'win32'];
@ -33,6 +38,16 @@ const Action = {
return process.env.GITHUB_WORKSPACE;
},
runnerContext(): RunnerContext {
const runnerTemporaryPath = process.env.RUNNER_TEMP ?? process.cwd();
const githubAction = process.env.GITHUB_ACTION ?? process.pid.toString();
return {
runnerTemporaryPath,
githubAction,
};
},
checkCompatibility() {
const currentPlatform = process.platform;
if (!Action.supportedPlatforms.includes(currentPlatform)) {

View File

@ -1,8 +1,33 @@
import { existsSync, mkdirSync } from 'fs';
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
import type { RunnerContext } from './action';
import { exec } from '@actions/exec';
import path from 'path';
/**
* Build a path for a docker --cidfile parameter. Docker will store the the created container.
* This path is stable for the whole execution of the action, so it can be executed with the same parameters
* multiple times and get the same result.
*/
const containerIdFilePath = parameters => {
const { runnerTemporaryPath, githubAction } = parameters;
return path.join(runnerTemporaryPath, `container_${githubAction}`);
};
const Docker = {
/**
* Remove a possible leftover container created by `Docker.run`.
*/
async ensureContainerRemoval(parameters: RunnerContext) {
const cidfile = containerIdFilePath(parameters);
if (!existsSync(cidfile)) {
return;
}
const container = readFileSync(cidfile, 'ascii').trim();
await exec(`docker`, ['rm', '--force', '--volumes', container], { silent: true });
rmSync(cidfile);
},
async run(image, parameters, silent = false) {
let runCommand = '';
switch (process.platform) {
@ -15,6 +40,7 @@ const Docker = {
default:
throw new Error(`Operation system, ${process.platform}, is not supported yet.`);
}
await exec(runCommand, undefined, { silent });
},
@ -40,12 +66,14 @@ const Docker = {
if (!existsSync(githubHome)) mkdirSync(githubHome);
const githubWorkflow = path.join(runnerTemporaryPath, '_github_workflow');
if (!existsSync(githubWorkflow)) mkdirSync(githubWorkflow);
const cidfile = containerIdFilePath(parameters);
const testPlatforms = (
testMode === 'all' ? ['playmode', 'editmode', 'COMBINE_RESULTS'] : [testMode]
).join(';');
return `docker run \
--workdir /github/workspace \
--cidfile "${cidfile}" \
--rm \
--env UNITY_LICENSE \
--env UNITY_LICENSE_FILE \
@ -112,6 +140,7 @@ const Docker = {
const githubHome = path.join(runnerTemporaryPath, '_github_home');
if (!existsSync(githubHome)) mkdirSync(githubHome);
const cidfile = containerIdFilePath(parameters);
const githubWorkflow = path.join(runnerTemporaryPath, '_github_workflow');
if (!existsSync(githubWorkflow)) mkdirSync(githubWorkflow);
const testPlatforms = (
@ -120,6 +149,7 @@ const Docker = {
return `docker run \
--workdir /github/workspace \
--cidfile "${cidfile}" \
--rm \
--env UNITY_LICENSE \
--env UNITY_LICENSE_FILE \

12
src/post.ts 100644
View File

@ -0,0 +1,12 @@
import * as core from '@actions/core';
import Action from './model/action';
import { Docker } from './model';
export async function run() {
try {
const parameters = Action.runnerContext();
await Docker.ensureContainerRemoval(parameters);
} catch (error: any) {
core.setFailed(error.message);
}
}