Browse Source

Add Duktape testrunner Node.js client

pull/640/head
Sami Vaarala 9 years ago
parent
commit
bf0d8a74b0
  1. 337
      testrunner/client-simple-node/client.js
  2. 26
      testrunner/client-simple-node/config.yaml.template
  3. 18
      testrunner/client-simple-node/package.json
  4. 89
      testrunner/client-simple-node/run_dukgxx.sh

337
testrunner/client-simple-node/client.js

@ -0,0 +1,337 @@
/*
* Testrunner client for simple commit jobs
*
* Node.js based default client intended for Linux, OSX, and Windows.
*/
var fs = require('fs');
var path = require('path');
var yaml = require('yamljs');
var Promise = require('bluebird');
var http = require('http');
var https = require('https');
var tmp = require('tmp');
var child_process = require('child_process');
var crypto = require('crypto');
/*
* Command line and config parsing
*/
var argv = require('minimist')(process.argv.slice(2));
console.log('Command line options: ' + JSON.stringify(argv));
var configFile = argv.config || './config.yaml';
console.log('Load config: ' + configFile);
var clientConfig = yaml.load(configFile);
/*
* Misc utils
*/
function sha1sum(x) {
return crypto.createHash('sha1').update(x).digest('hex');
}
function sha1sumFile(x) {
return sha1sum(fs.readFileSync(x));
}
function assert(x) {
if (x) { return x; }
throw new Error('assertion failed');
}
/*
* HTTP(S) helpers
*/
var serverTrustRoot = fs.readFileSync(clientConfig.serverCertificate);
// Path -> file data
var fileCache = {};
function getBasicAuthHeader() {
return 'Basic ' + new Buffer(assert(clientConfig.clientAuthUsername) + ':' + assert(clientConfig.clientAuthPassword)).toString('base64');
}
function getUserAgent() {
return 'testrunner-client';
}
function serverAuthCheck(res) {
var auth = res.headers['x-testrunner-authenticator']; // lowercase intentionally
if (typeof auth !== 'string' ||
auth !== assert(clientConfig.serverAuthPassword)) {
throw new Error('server not authorized');
}
}
function postJson(path, request, cb) {
return new Promise(function (resolve, reject) {
var requestData = new Buffer(JSON.stringify(request), 'utf8');
var headers = {
'User-Agent': getUserAgent(),
'Authorization': getBasicAuthHeader(),
'Content-Type': 'application/json',
'Content-Length': requestData.length,
};
var options = {
host: clientConfig.serverHost,
port: clientConfig.serverPort,
ca: serverTrustRoot,
rejectUnauthorized: true,
path: path,
method: 'POST',
auth: assert(clientConfig.clientAuthUsername) + ':' + assert(clientConfig.clientAuthPassword),
headers: headers
};
console.log('sending POST ' + path);
var req = https.request(options, function (res) {
//res.setEncoding('utf8'); // we just want binary
var buffers = [];
res.on('data', function (data) {
try {
buffers.push(data);
} catch (e) {
console.log(e);
}
});
res.on('end', function () {
try {
serverAuthCheck(res);
var data = Buffer.concat(buffers);
var rep = JSON.parse(data.toString('utf8'));
} catch (e) {
reject(e);
return;
}
resolve(rep);
});
});
req.on('error', function (err) {
reject(err);
});
req.write(requestData);
req.end();
});
}
function getFile(path, cb) {
return new Promise(function (resolve, reject) {
var headers = {
'User-Agent': getUserAgent(),
'Authorization': getBasicAuthHeader()
};
var options = {
host: clientConfig.serverHost,
port: clientConfig.serverPort,
ca: serverTrustRoot,
rejectUnauthorized: true,
path: path,
method: 'GET',
auth: assert(clientConfig.clientAuthUsername) + ':' + assert(clientConfig.clientAuthPassword),
headers: headers
};
console.log('sending GET ' + path);
var req = https.request(options, function (res) {
//res.setEncoding('utf8'); // we just want binary
var buffers = [];
res.on('data', function (data) {
try {
buffers.push(data);
} catch (e) {
console.log(e);
}
});
res.on('end', function () {
try {
serverAuthCheck(res);
var data = Buffer.concat(buffers);
} catch (e) {
reject(e);
return;
}
resolve(data);
});
});
req.on('error', function (err) {
reject(err);
});
req.end();
});
}
function cachedGetFile(path) {
if (fileCache[path]) {
return new Promise(function (resolve, reject) {
resolve(fileCache[path]);
});
} else {
return getFile(path).then(function (data) {
fileCache[path] = data;
return data;
});
}
}
/*
* Simple commit jobs
*/
function processSimpleScriptJob(rep) {
var context, repo, repoFull, repoCloneUrl, sha, script;
var tmpDir;
var tmpDir = tmp.dirSync({
mode: 0750,
prefix: 'testrunner-' + context + '-',
unsafeCleanup: true
});
return new Promise(function (resolve, reject) {
var i;
context = assert(rep.context);
repo = assert(rep.repo);
repoFull = assert(rep.repo_full);
repoCloneUrl = assert(rep.repo_clone_url);
sha = assert(rep.sha);
for (i = 0; i < clientConfig.supportedContexts.length; i++) {
if (clientConfig.supportedContexts[i].context === assert(rep.context)) {
script = clientConfig.supportedContexts[i].script;
}
}
if (!script) {
reject(new Error('unsupported context: ' + context));
return;
}
console.log('start simple commit test, repo ' + repoCloneUrl + ', sha ' + sha + ', context ' + context);
var args = [ assert(repoFull), assert(repoCloneUrl), assert(sha), assert(context), assert(tmpDir.name) ];
var cld = child_process.spawn(assert(script), args, { cwd: '/tmp' });
var buffers = [];
cld.stdout.on('data', function (data) {
buffers.push(data);
});
cld.stderr.on('data', function (data) {
buffers.push(data); // just interleave stdout/stderr for now
});
cld.on('close', function (code) {
resolve({ code: code, data: Buffer.concat(buffers) });
});
}).then(function (res) {
// Test execution finished, test case may have succeeded or failed.
console.log('finished simple commit test, repo ' + repoCloneUrl + ', sha ' + sha + ', context ' + context + ', code ' + res.code);
//console.log(res.data.toString());
// Initial heuristic for providing descriptions from test script,
// could also use an explicit output file. Match last occurrence
// on purpose so that the script can always override a previous
// status if necessary.
var desc = null;
try {
desc = (function () {
var txt = res.data.toString('utf8'); // XXX: want something lenient
var re = /^TESTRUNNER_DESCRIPTION: (.*?)$/gm;
var m;
var out = null;
while ((m = re.exec(txt)) !== null) {
out = m[1];
}
return out;
})();
} catch (e) {
console.log(e);
}
postJson('/finish-commit-simple', {
repo: assert(repo),
repo_full: assert(repoFull),
repo_clone_url: assert(repoCloneUrl),
sha: assert(sha),
context: assert(context),
state: res.code === 0 ? 'success' : 'failure',
description: desc || (res.code === 0 ? 'Success' : 'Failure'),
text: res.data.toString('base64')
}).then(function (rep) {
//console.log(rep);
}).catch(function (err) {
console.log(err); // XXX: chain error
});
}).catch(function (err) {
// Test execution attempt failed (which is different from a test case
// failing).
console.log('FAILED simple commit test, repo ' + repoCloneUrl + ', sha ' + sha + ', context ' + context);
console.log(err);
var data = new Buffer(String(err.stack || err));
// XXX: indicate possibly transient nature of error
postJson('/finish-commit-simple', {
repo: assert(repo),
repo_full: assert(repoFull),
repo_clone_url: assert(repoCloneUrl),
sha: assert(sha),
context: assert(context),
state: 'failure',
description: 'Failed: ' + String(err),
text: data.toString('base64')
}).then(function (rep) {
//console.log(rep);
}).catch(function (err) {
console.log(err); // XXX: chain error
});
}).finally(function () {
if (tmpDir) {
tmpDir.removeCallback();
console.log('cleaned up ' + tmpDir.name);
}
});
}
function requestAndExecute() {
var contexts = [];
var i;
for (i = 0; i < clientConfig.supportedContexts.length; i++) {
contexts.push(clientConfig.supportedContexts[i].context);
}
postJson('/get-commit-simple', {
contexts: contexts
}).then(function (rep) {
console.log(rep);
if (rep.error_code) {
throw new Error('job failed; error code ' + rep.error_code + ': ' + rep.error_description);
}
return processSimpleScriptJob(rep);
}).then(function () {
setTimeout(function () { requestAndExecute(); }, clientConfig.sleepJobSuccess);
}).catch(function (err) {
// XXX: differentiate between TIMEOUT and other errors
console.log('job failed; sleep a bit and retry:', err);
console.log(err.stack);
setTimeout(function () { requestAndExecute(); }, clientConfig.sleepJobFailure);
});
setTimeout(function () {}, 10000000); // avoid exit
}
function main() {
requestAndExecute();
}
main();

26
testrunner/client-simple-node/config.yaml.template

@ -0,0 +1,26 @@
# Testrunner client example configuration
# Testrunner server host/port.
serverHost: 'localhost'
serverPort: 9080
# Testrunner server SSL trust root (Duktape CA).
serverCertificate: 'server.crt'
# Testrunner API authentication.
serverAuthPassword: 'foobarx'
clientAuthUsername: 'foouser'
clientAuthPassword: 'foopass'
# Sleeps between successful/failed jobs.
sleepJobSuccess: 10000
sleepJobFailure: 60000
# Supported simple commit job types (contexts). Each context has a string
# name which appears directly in Github web UI, and a script which is used
# to execute test jobs of that type.
supportedContexts:
- context: 'qecmatest'
script: '/path/to/run_qecmatest.sh' # script paths must be absolute
- context: 'apitest'
script: '/path/to/run_apitest.sh'

18
testrunner/client-simple-node/package.json

@ -0,0 +1,18 @@
{
"name": "testrunner-client",
"version": "0.1.0",
"description": "Duktape testrunner client",
"author": {
"name": "Sami Vaarala",
"email": "sami.vaarala@iki.fi"
},
"dependencies": {
"bluebird": "^2.9.25",
"crypto": "0.0.3",
"https": "^1.0.0",
"yamljs": "^0.2.2",
"tmp": "~0.0.27",
"minimist": "~1.2.0"
},
"main": "client.js"
}

89
testrunner/client-simple-node/run_dukgxx.sh

@ -0,0 +1,89 @@
#!/bin/sh
# Full name of repo, e.g. "svaarala/duktape".
REPOFULLNAME=$1
# Repo HTTPS clone URL, e.g. "https://github.com/svaarala/duktape.git".
REPOCLONEURL=$2
# Commit hash.
SHA=$3
# Context, e.g. "x64-qecmatest".
CONTEXT=$4
# Automatic temporary directory created by testclient, automatically
# deleted (recursively) by testclient.
TEMPDIR=$5
# Directory holding repo tar.gz clone snapshots for faster test init.
# Example: /tmp/repo-snapshots/svaarala/duktape.tar.gz.
REPO_TARGZS=/tmp/repo-snapshots
set -e
echo "*** Run duk-g++ test: `date`"
echo "REPOFULLNAME=$REPOFULLNAME"
echo "REPOCLONEURL=$REPOCLONEURL"
echo "SHA=$SHA"
echo "CONTEXT=$CONTEXT"
if [ "x$TEMPDIR" = "x" ]; then
echo "Missing TEMPDIR."
exit 1
fi
cd "$TEMPDIR"
if echo "$REPOFULLNAME" | grep -E '[[:space:]\.]'; then
echo "Repo full name contains invalid characters"
exit 1
fi
if [ "$REPOFULLNAME" != "svaarala/duktape" ]; then
echo "Repo is not whitelisted"
exit 1
fi
# This isn't great security-wise, but $REPOFULLNAME has been checked not to
# contain dots (e.g. '../'). It'd be better to resolve the absolute filename
# and ensure it begins with $REPO_TARGZS.
TARGZ=$REPO_TARGZS/$REPOFULLNAME.tar.gz
echo "Using repo tar.gz: '$TARGZ'"
if [ ! -f "$TARGZ" ]; then
echo "Repo snapshot doesn't exist: $TARGZ."
exit 1
fi
echo "Git preparations"
cd "$TEMPDIR"
mkdir repo
cd repo
tar -x --strip-components 1 -z -f "$TARGZ"
git clean -f
git checkout master
git pull --rebase
git clean -f
git checkout "$SHA"
echo ""
echo "Running: make clean duk-g++"
make clean duk-g++
echo ""
echo "Hello world test"
./duk-g++ -e "print('hello world!');" >hello.out
echo "hello world!" >hello.require
cat hello.out
# If stdout contains "TESTRUNNER_DESCRIPTION: xxx" it will become the Github
# status description. Last occurrence wins. If no such line is printed, the
# test client will use a generic "Success" or "Failure" text.
echo "TESTRUNNER_DESCRIPTION: DUMMY"
if cmp hello.out hello.require; then
echo "match"
echo "TESTRUNNER_DESCRIPTION: Hello world success"
exit 0
else
echo "no match"
echo "TESTRUNNER_DESCRIPTION: Hello world failed"
exit 1
fi
Loading…
Cancel
Save