cleanup created docker containers

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/198/head
Michal Cichra 2022-11-02 10:53:25 +01:00
parent 5263cf0ab1
commit e35fd83607
11 changed files with 293 additions and 65 deletions

View File

@ -62,4 +62,5 @@ branding:
color: 'gray-dark' color: 'gray-dark'
runs: runs:
using: 'node16' 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"; "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) { var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k; if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[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 })); Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.run = void 0;
const core = __importStar(__nccwpck_require__(2186)); const core = __importStar(__nccwpck_require__(2186));
const model_1 = __nccwpck_require__(1359); const model_1 = __nccwpck_require__(1359);
function run() { function run() {
@ -44,10 +93,9 @@ function run() {
const { workspace, actionFolder } = model_1.Action; 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 { 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 baseImage = new model_1.ImageTag({ editorVersion, customImage });
const runnerTemporaryPath = process.env.RUNNER_TEMP; const runnerContext = model_1.Action.runnerContext();
try { try {
yield model_1.Docker.run(baseImage, { yield model_1.Docker.run(baseImage, Object.assign({ actionFolder,
actionFolder,
editorVersion, editorVersion,
workspace, workspace,
projectPath, projectPath,
@ -59,9 +107,7 @@ function run() {
sshAgent, sshAgent,
gitPrivateToken, gitPrivateToken,
githubToken, githubToken,
runnerTemporaryPath, chownFilesTo }, runnerContext));
chownFilesTo,
});
} }
finally { finally {
yield model_1.Output.setArtifactsPath(artifactsPath); yield model_1.Output.setArtifactsPath(artifactsPath);
@ -79,7 +125,7 @@ function run() {
} }
}); });
} }
run(); exports.run = run;
/***/ }), /***/ }),
@ -119,6 +165,15 @@ const Action = {
get workspace() { get workspace() {
return process.env.GITHUB_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() { checkCompatibility() {
const currentPlatform = process.platform; const currentPlatform = process.platform;
if (!Action.supportedPlatforms.includes(currentPlatform)) { if (!Action.supportedPlatforms.includes(currentPlatform)) {
@ -152,7 +207,30 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
const fs_1 = __nccwpck_require__(7147); const fs_1 = __nccwpck_require__(7147);
const exec_1 = __nccwpck_require__(1514); const exec_1 = __nccwpck_require__(1514);
const path_1 = __importDefault(__nccwpck_require__(1017)); 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 = { 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) { run(image, parameters, silent = false) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
let runCommand = ''; let runCommand = '';
@ -177,9 +255,11 @@ const Docker = {
const githubWorkflow = path_1.default.join(runnerTemporaryPath, '_github_workflow'); const githubWorkflow = path_1.default.join(runnerTemporaryPath, '_github_workflow');
if (!(0, fs_1.existsSync)(githubWorkflow)) if (!(0, fs_1.existsSync)(githubWorkflow))
(0, fs_1.mkdirSync)(githubWorkflow); (0, fs_1.mkdirSync)(githubWorkflow);
const cidfile = containerIdFilePath(parameters);
const testPlatforms = (testMode === 'all' ? ['playmode', 'editmode', 'COMBINE_RESULTS'] : [testMode]).join(';'); const testPlatforms = (testMode === 'all' ? ['playmode', 'editmode', 'COMBINE_RESULTS'] : [testMode]).join(';');
return `docker run \ return `docker run \
--workdir /github/workspace \ --workdir /github/workspace \
--cidfile "${cidfile}" \
--rm \ --rm \
--env UNITY_LICENSE \ --env UNITY_LICENSE \
--env UNITY_LICENSE_FILE \ --env UNITY_LICENSE_FILE \
@ -228,12 +308,14 @@ const Docker = {
const githubHome = path_1.default.join(runnerTemporaryPath, '_github_home'); const githubHome = path_1.default.join(runnerTemporaryPath, '_github_home');
if (!(0, fs_1.existsSync)(githubHome)) if (!(0, fs_1.existsSync)(githubHome))
(0, fs_1.mkdirSync)(githubHome); (0, fs_1.mkdirSync)(githubHome);
const cidfile = containerIdFilePath(parameters);
const githubWorkflow = path_1.default.join(runnerTemporaryPath, '_github_workflow'); const githubWorkflow = path_1.default.join(runnerTemporaryPath, '_github_workflow');
if (!(0, fs_1.existsSync)(githubWorkflow)) if (!(0, fs_1.existsSync)(githubWorkflow))
(0, fs_1.mkdirSync)(githubWorkflow); (0, fs_1.mkdirSync)(githubWorkflow);
const testPlatforms = (testMode === 'all' ? ['playmode', 'editmode', 'COMBINE_RESULTS'] : [testMode]).join(';'); const testPlatforms = (testMode === 'all' ? ['playmode', 'editmode', 'COMBINE_RESULTS'] : [testMode]).join(';');
return `docker run \ return `docker run \
--workdir /github/workspace \ --workdir /github/workspace \
--cidfile "${cidfile}" \
--rm \ --rm \
--env UNITY_LICENSE \ --env UNITY_LICENSE \
--env UNITY_LICENSE_FILE \ --env UNITY_LICENSE_FILE \
@ -1098,6 +1180,63 @@ const UnityVersionParser = {
exports["default"] = 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: /***/ 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", "license": "MIT",
"scripts": { "scripts": {
"prebuild": "yarn", "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", "lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
"format": "prettier --write \"src/**/*.{js,ts}\"", "format": "prettier --write \"src/**/*.{js,ts}\"",
"test": "jest", "test": "jest",

View File

@ -1,60 +1,31 @@
import * as core from '@actions/core'; import { basename } from 'path';
import { Action, Docker, ImageTag, Input, Output, ResultsCheck } from './model';
async function run() { import { run as main } from './main';
try { import { run as post } from './post';
Action.checkCompatibility();
const { workspace, actionFolder } = Action; /*
const { * GitHub Action can provide multiple executable entrypoints (pre, main, post),
editorVersion, * but it is complicated process to generate multiple `.js` files with `ncc`.
customImage, * So we rather generate just one entrypoint, that is symlinked to multiple locations (main.js and post.js).
projectPath, * Then when GitHub Action Runner executes it as `node path/to/main.js` and `node path/to/post.js`,
customParameters, * it can read arguments it was executed with and decide which file to execute.
testMode, * The argv[0] is going to be a full path to `node` executable and
coverageOptions, * the argv[1] is going to be the full path to the script.
artifactsPath, * In case index.js would be marked executable and executed directly without the argv[1] it defaults to "main.js".
useHostNetwork, */
sshAgent, async function run([_, name = 'main.js']: string[]) {
gitPrivateToken, const script = basename(name);
githubToken,
checkName,
chownFilesTo,
} = Input.getFromUser();
const baseImage = new ImageTag({ editorVersion, customImage });
const runnerTemporaryPath = process.env.RUNNER_TEMP;
try { switch (script) {
await Docker.run(baseImage, { case 'main.js':
actionFolder, await main();
editorVersion, break;
workspace, case 'post.js':
projectPath, await post();
customParameters, break;
testMode, default:
coverageOptions, throw new Error(`Unknown script argument: '${script}'`);
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);
} }
} }
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'; import path from 'path';
export interface RunnerContext {
runnerTemporaryPath: string;
githubAction: string;
}
const Action = { const Action = {
get supportedPlatforms() { get supportedPlatforms() {
return ['linux', 'win32']; return ['linux', 'win32'];
@ -33,6 +38,16 @@ const Action = {
return process.env.GITHUB_WORKSPACE; 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() { checkCompatibility() {
const currentPlatform = process.platform; const currentPlatform = process.platform;
if (!Action.supportedPlatforms.includes(currentPlatform)) { 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 { exec } from '@actions/exec';
import path from 'path'; 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 = { 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) { async run(image, parameters, silent = false) {
let runCommand = ''; let runCommand = '';
switch (process.platform) { switch (process.platform) {
@ -15,6 +40,7 @@ const Docker = {
default: default:
throw new Error(`Operation system, ${process.platform}, is not supported yet.`); throw new Error(`Operation system, ${process.platform}, is not supported yet.`);
} }
await exec(runCommand, undefined, { silent }); await exec(runCommand, undefined, { silent });
}, },
@ -40,12 +66,14 @@ const Docker = {
if (!existsSync(githubHome)) mkdirSync(githubHome); if (!existsSync(githubHome)) mkdirSync(githubHome);
const githubWorkflow = path.join(runnerTemporaryPath, '_github_workflow'); const githubWorkflow = path.join(runnerTemporaryPath, '_github_workflow');
if (!existsSync(githubWorkflow)) mkdirSync(githubWorkflow); if (!existsSync(githubWorkflow)) mkdirSync(githubWorkflow);
const cidfile = containerIdFilePath(parameters);
const testPlatforms = ( const testPlatforms = (
testMode === 'all' ? ['playmode', 'editmode', 'COMBINE_RESULTS'] : [testMode] testMode === 'all' ? ['playmode', 'editmode', 'COMBINE_RESULTS'] : [testMode]
).join(';'); ).join(';');
return `docker run \ return `docker run \
--workdir /github/workspace \ --workdir /github/workspace \
--cidfile "${cidfile}" \
--rm \ --rm \
--env UNITY_LICENSE \ --env UNITY_LICENSE \
--env UNITY_LICENSE_FILE \ --env UNITY_LICENSE_FILE \
@ -112,6 +140,7 @@ const Docker = {
const githubHome = path.join(runnerTemporaryPath, '_github_home'); const githubHome = path.join(runnerTemporaryPath, '_github_home');
if (!existsSync(githubHome)) mkdirSync(githubHome); if (!existsSync(githubHome)) mkdirSync(githubHome);
const cidfile = containerIdFilePath(parameters);
const githubWorkflow = path.join(runnerTemporaryPath, '_github_workflow'); const githubWorkflow = path.join(runnerTemporaryPath, '_github_workflow');
if (!existsSync(githubWorkflow)) mkdirSync(githubWorkflow); if (!existsSync(githubWorkflow)) mkdirSync(githubWorkflow);
const testPlatforms = ( const testPlatforms = (
@ -120,6 +149,7 @@ const Docker = {
return `docker run \ return `docker run \
--workdir /github/workspace \ --workdir /github/workspace \
--cidfile "${cidfile}" \
--rm \ --rm \
--env UNITY_LICENSE \ --env UNITY_LICENSE \
--env UNITY_LICENSE_FILE \ --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);
}
}