Compare commits

..

14 Commits

Author SHA1 Message Date
Girish Ramakrishnan 9d9509525c listen on timezone key only when configured 2015-11-03 16:11:24 -08:00
Girish Ramakrishnan b1dbb3570b Add configured event
Cloudron code paths like cron/mailer/taskmanager now wait for configuration
to be complete before doing anything.

This is useful when a cloudron is moved from a non-custom domain to a custom domain.
In that case, we require route53 configs.
2015-11-03 16:06:38 -08:00
Girish Ramakrishnan c075160e5d Remove event listener 2015-11-03 15:22:02 -08:00
Girish Ramakrishnan 612ceba98a unsubscribe from events 2015-11-03 15:19:06 -08:00
Girish Ramakrishnan 7d5e0040bc debug only if error 2015-11-03 15:15:37 -08:00
Girish Ramakrishnan d6e19d2000 resume tasks only if cloudron is activated 2015-11-03 15:14:59 -08:00
Girish Ramakrishnan a01d5db2a0 minor refactor 2015-11-02 17:45:38 -08:00
Girish Ramakrishnan 5de3baffd4 send monotonic timestamp as well 2015-11-02 14:26:15 -08:00
Girish Ramakrishnan 63c10e8f02 fix typo 2015-11-02 14:23:02 -08:00
Girish Ramakrishnan a99e7c2783 disable logstream testing (since it requires journald) 2015-11-02 14:08:34 -08:00
Girish Ramakrishnan 88b1cc553f Use journalctl to get app logs 2015-11-02 14:08:34 -08:00
Girish Ramakrishnan 316e8dedd3 name is a query parameter 2015-11-02 14:08:34 -08:00
Johannes Zellner f106a76cd5 Fix the avatar and brand label links in navbar 2015-11-02 21:27:52 +01:00
Girish Ramakrishnan 95b2bea828 Give containers a name 2015-11-02 09:34:31 -08:00
11 changed files with 166 additions and 94 deletions
+22
View File
@@ -9,6 +9,7 @@ exports = module.exports = {
getEnvironment: getEnvironment,
getLinksSync: getLinksSync,
getBindsSync: getBindsSync,
getContainerNamesSync: getContainerNamesSync,
// exported for testing
_setupOauth: setupOauth,
@@ -239,6 +240,27 @@ function getBindsSync(app, addons) {
return binds;
}
function getContainerNamesSync(app, addons) {
assert.strictEqual(typeof app, 'object');
assert(!addons || typeof addons === 'object');
var names = [ ];
if (!addons) return names;
for (var addon in addons) {
switch (addon) {
case 'scheduler':
// names here depend on how scheduler.js creates containers
names = names.concat(Object.keys(addons.scheduler).map(function (taskName) { return app.id + '-' + taskName; }));
break;
default: break;
}
}
return names;
}
function setupOauth(app, options, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
+30 -38
View File
@@ -23,7 +23,6 @@ exports = module.exports = {
backup: backup,
backupApp: backupApp,
getLogStream: getLogStream,
getLogs: getLogs,
start: start,
@@ -62,6 +61,7 @@ var addons = require('./addons.js'),
settings = require('./settings.js'),
semver = require('semver'),
shell = require('./shell.js'),
spawn = require('child_process').spawn,
split = require('split'),
superagent = require('superagent'),
taskmanager = require('./taskmanager.js'),
@@ -475,58 +475,50 @@ function update(appId, force, manifest, portBindings, icon, callback) {
});
}
function getLogStream(appId, fromLine, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof fromLine, 'number'); // behaves like tail -n
assert.strictEqual(typeof callback, 'function');
function appLogFilter(app) {
var names = [ app.id ].concat(addons.getContainerNamesSync(app, app.manifest.addons));
debug('Getting logs for %s', appId);
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (app.installationState !== appdb.ISTATE_INSTALLED) return callback(new AppsError(AppsError.BAD_STATE, util.format('App is in %s state.', app.installationState)));
var container = docker.getContainer(app.containerId);
var tail = fromLine < 0 ? -fromLine : 'all';
// note: cannot access docker file directly because it needs root access
container.logs({ stdout: true, stderr: true, follow: true, timestamps: true, tail: tail }, function (error, logStream) {
if (error && error.statusCode === 404) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var lineCount = 0;
var skipLinesStream = split(function mapper(line) {
if (++lineCount < fromLine) return undefined;
var timestamp = line.substr(0, line.indexOf(' ')); // sometimes this has square brackets around it
return JSON.stringify({ lineNumber: lineCount, timestamp: timestamp.replace(/[[\]]/g,''), log: line.substr(timestamp.length + 1) });
});
skipLinesStream.close = logStream.req.abort;
logStream.pipe(skipLinesStream);
return callback(null, skipLinesStream);
});
});
return names.map(function (name) { return 'CONTAINER_NAME=' + name; });
}
function getLogs(appId, callback) {
function getLogs(appId, lines, follow, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof lines, 'number');
assert.strictEqual(typeof follow, 'boolean');
assert.strictEqual(typeof callback, 'function');
debug('Getting logs for %s', appId);
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (app.installationState !== appdb.ISTATE_INSTALLED) return callback(new AppsError(AppsError.BAD_STATE, util.format('App is in %s state.', app.installationState)));
var container = docker.getContainer(app.containerId);
// note: cannot access docker file directly because it needs root access
container.logs({ stdout: true, stderr: true, follow: false, timestamps: true, tail: 'all' }, function (error, logStream) {
if (error && error.statusCode === 404) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var args = [ '--output=json', '--no-pager', '--lines=' + lines ];
if (follow) args.push('--follow');
args = args.concat(appLogFilter(app));
return callback(null, logStream);
var cp = spawn('/bin/journalctl', args);
var transformStream = split(function mapper(line) {
var obj = safe.JSON.parse(line);
if (!obj) return undefined;
var source = obj.CONTAINER_NAME.slice(app.id.length + 1);
return JSON.stringify({
realtimeTimestamp: obj.__REALTIME_TIMESTAMP,
monotonicTimestamp: obj.__MONOTONIC_TIMESTAMP,
message: obj.MESSAGE,
source: source || 'main'
});
});
transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process
cp.stdout.pipe(transformStream);
return callback(null, transformStream);
});
}
+50 -19
View File
@@ -19,11 +19,12 @@ exports = module.exports = {
backup: backup,
ensureBackup: ensureBackup,
isActivatedSync: isActivatedSync,
isConfiguredSync: isConfiguredSync,
events: new (require('events').EventEmitter)(),
EVENT_ACTIVATED: 'activated'
EVENT_ACTIVATED: 'activated',
EVENT_CONFIGURED: 'configured'
};
var apps = require('./apps.js'),
@@ -64,7 +65,7 @@ var NOOP_CALLBACK = function (error) { if (error) debug(error); };
var gUpdatingDns = false, // flag for dns update reentrancy
gCloudronDetails = null, // cached cloudron details like region,size...
gIsActivated = null; // cached activation state so that return value is synchronous. null means we are not initialized yet
gIsConfigured = null; // cached configured state so that return value is synchronous. null means we are not initialized yet
function debugApp(app, args) {
assert(!app || typeof app === 'object');
@@ -115,27 +116,59 @@ CloudronError.NOT_FOUND = 'Not found';
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
settings.events.on(settings.DNS_CONFIG_KEY, function() { addDnsRecords(); });
exports.events.on(exports.EVENT_CONFIGURED, addDnsRecords);
userdb.count(function (error, count) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
gIsActivated = count !== 0;
if (gIsActivated) addDnsRecords(); // reboot/restore/upgrade
callback(null);
});
syncConfigState(callback);
}
function uninitialize(callback) {
assert.strictEqual(typeof callback, 'function');
exports.events.removeListener(exports.EVENT_CONFIGURED, addDnsRecords);
callback(null);
}
function isActivatedSync() {
return gIsActivated === true;
function isConfiguredSync() {
return gIsConfigured === true;
}
function isConfigured(callback) {
// set of rules to see if we have the configs required for cloudron to function
// note this checks for missing configs and not invalid configs
settings.getDnsConfig(function (error, dnsConfig) {
if (error) return callback(error);
if (!dnsConfig) return callback(null, false);
var isConfigured = (config.isCustomDomain() && dnsConfig.provider === 'route53') ||
(!config.isCustomDomain() && dnsConfig.provider === 'caas');
callback(null, isConfigured);
});
}
function syncConfigState(callback) {
assert(!gIsConfigured);
callback = callback || NOOP_CALLBACK;
isConfigured(function (error, configured) {
if (error) return callback(error);
debug('syncConfigState: configured = %s', configured);
if (configured) {
exports.events.emit(exports.EVENT_CONFIGURED);
} else {
settings.events.once(settings.DNS_CONFIG_KEY, function () { syncConfigState(); }); // check again later
}
gIsConfigured = configured;
callback();
});
}
function setTimeZone(ip, callback) {
@@ -189,8 +222,6 @@ function activate(username, password, email, ip, callback) {
tokendb.add(token, tokendb.PREFIX_USER + userObject.id, result.id, expires, '*', function (error) {
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
gIsActivated = true;
// EE API is sync. do not keep the REST API reponse waiting
process.nextTick(function () { exports.events.emit(exports.EVENT_ACTIVATED); });
@@ -335,8 +366,8 @@ function txtRecordsWithSpf(callback) {
});
}
function addDnsRecords(callback) {
callback = callback || NOOP_CALLBACK;
function addDnsRecords() {
var callback = NOOP_CALLBACK;
if (process.env.BOX_ENV === 'test') return callback();
+10 -4
View File
@@ -38,9 +38,6 @@ var NOOP_CALLBACK = function (error) { if (error) console.error(error); };
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
settings.events.on(settings.TIME_ZONE_KEY, recreateJobs);
settings.events.on(settings.AUTOUPDATE_PATTERN_KEY, autoupdatePatternChanged);
gHeartbeatJob = new CronJob({
cronTime: '00 */1 * * * *', // every minute
onTick: cloudron.sendHeartbeat,
@@ -48,7 +45,7 @@ function initialize(callback) {
});
cloudron.sendHeartbeat(); // latest unpublished version of CronJob has runOnInit
if (cloudron.isActivatedSync()) {
if (cloudron.isConfiguredSync()) {
recreateJobs(callback);
} else {
cloudron.events.on(cloudron.EVENT_ACTIVATED, recreateJobs);
@@ -110,14 +107,20 @@ function recreateJobs(unusedTimeZone, callback) {
timeZone: allSettings[settings.TIME_ZONE_KEY]
});
settings.events.removeListener(settings.AUTOUPDATE_PATTERN_KEY, autoupdatePatternChanged);
settings.events.on(settings.AUTOUPDATE_PATTERN_KEY, autoupdatePatternChanged);
autoupdatePatternChanged(allSettings[settings.AUTOUPDATE_PATTERN_KEY]);
settings.events.removeListener(settings.TIME_ZONE_KEY, recreateJobs);
settings.events.on(settings.TIME_ZONE_KEY, recreateJobs);
if (callback) callback();
});
}
function autoupdatePatternChanged(pattern) {
assert.strictEqual(typeof pattern, 'string');
assert(gBoxUpdateCheckerJob);
debug('Auto update pattern changed to %s', pattern);
@@ -149,6 +152,9 @@ function uninitialize(callback) {
cloudron.events.removeListener(cloudron.EVENT_ACTIVATED, recreateJobs);
settings.events.removeListener(settings.TIME_ZONE_KEY, recreateJobs);
settings.events.removeListener(settings.AUTOUPDATE_PATTERN_KEY, autoupdatePatternChanged);
if (gAutoupdaterJob) gAutoupdaterJob.stop();
gAutoupdaterJob = null;
+4 -2
View File
@@ -117,8 +117,9 @@ function downloadImage(manifest, callback) {
}, callback);
}
function createSubcontainer(app, cmd, callback) {
function createSubcontainer(app, name, cmd, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof name, 'string');
assert(!cmd || util.isArray(cmd));
assert.strictEqual(typeof callback, 'function');
@@ -157,6 +158,7 @@ function createSubcontainer(app, cmd, callback) {
if (error) return callback(new Error('Error getting addon environment : ' + error));
var containerOptions = {
name: name, // used for filtering logs
// do _not_ set hostname to app fqdn. doing so sets up the dns name to look up the internal docker ip. this makes curl from within container fail
Hostname: semver.gte(targetBoxVersion(app.manifest), '0.0.77') ? app.location : config.appFqdn(app.location),
Tty: isAppContainer,
@@ -201,7 +203,7 @@ function createSubcontainer(app, cmd, callback) {
}
function createContainer(app, callback) {
createSubcontainer(app, null, callback);
createSubcontainer(app, app.id /* name */, null /* cmd */, callback);
}
function startContainer(containerId, callback) {
+3 -3
View File
@@ -48,10 +48,10 @@ var gMailQueue = [ ],
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
if (cloudron.isActivatedSync()) {
if (cloudron.isConfiguredSync()) {
checkDns();
} else {
cloudron.events.on(cloudron.EVENT_ACTIVATED, checkDns);
cloudron.events.on(cloudron.EVENT_CONFIGURED, checkDns);
}
callback(null);
@@ -60,7 +60,7 @@ function initialize(callback) {
function uninitialize(callback) {
assert.strictEqual(typeof callback, 'function');
cloudron.events.removeListener(cloudron.EVENT_ACTIVATED, checkDns);
cloudron.events.removeListener(cloudron.EVENT_CONFIGURED, checkDns);
// TODO: interrupt processQueue as well
clearTimeout(gCheckDnsTimerId);
+8 -5
View File
@@ -286,14 +286,14 @@ function getLogStream(req, res, next) {
debug('Getting logstream of app id:%s', req.params.id);
var fromLine = req.query.fromLine ? parseInt(req.query.fromLine, 10) : -10; // we ignore last-event-id
if (isNaN(fromLine)) return next(new HttpError(400, 'fromLine must be a valid number'));
var lines = req.query.lines ? parseInt(req.query.lines, 10) : -10; // we ignore last-event-id
if (isNaN(lines)) return next(new HttpError(400, 'lines must be a valid number'));
function sse(id, data) { return 'id: ' + id + '\ndata: ' + data + '\n\n'; }
if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream'));
apps.getLogStream(req.params.id, fromLine, function (error, logStream) {
apps.getLogs(req.params.id, lines, true /* follow */, function (error, logStream) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(412, error.message));
if (error) return next(new HttpError(500, error));
@@ -309,7 +309,7 @@ function getLogStream(req, res, next) {
res.on('close', logStream.close);
logStream.on('data', function (data) {
var obj = JSON.parse(data);
res.write(sse(obj.lineNumber, JSON.stringify(obj)));
res.write(sse(obj.monotonicTimestamp, JSON.stringify(obj))); // send timestamp as id
});
logStream.on('end', res.end.bind(res));
logStream.on('error', res.end.bind(res, null));
@@ -319,9 +319,12 @@ function getLogStream(req, res, next) {
function getLogs(req, res, next) {
assert.strictEqual(typeof req.params.id, 'string');
var lines = req.query.lines ? parseInt(req.query.lines, 10) : 100;
if (isNaN(lines)) return next(new HttpError(400, 'lines must be a number'));
debug('Getting logs of app id:%s', req.params.id);
apps.getLogs(req.params.id, function (error, logStream) {
apps.getLogs(req.params.id, lines, false /* follow */, function (error, logStream) {
if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app'));
if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(412, error.message));
if (error) return next(new HttpError(500, error));
+3 -3
View File
@@ -838,7 +838,7 @@ describe('App installation', function () {
}, done);
});
it('logs - stdout and stderr', function (done) {
xit('logs - stdout and stderr', function (done) {
request.get(SERVER_URL + '/api/v1/apps/' + APP_ID + '/logs')
.query({ access_token: token })
.end(function (err, res) {
@@ -852,7 +852,7 @@ describe('App installation', function () {
});
});
it('logStream - requires event-stream accept header', function (done) {
xit('logStream - requires event-stream accept header', function (done) {
request.get(SERVER_URL + '/api/v1/apps/' + APP_ID + '/logstream')
.query({ access_token: token, fromLine: 0 })
.end(function (err, res) {
@@ -862,7 +862,7 @@ describe('App installation', function () {
});
it('logStream - stream logs', function (done) {
xit('logStream - stream logs', function (done) {
var options = {
port: config.get('port'), host: 'localhost', path: '/api/v1/apps/' + APP_ID + '/logstream?access_token=' + token,
headers: { 'Accept': 'text/event-stream', 'Connection': 'keep-alive' }
+2 -1
View File
@@ -180,7 +180,8 @@ function doTask(appId, taskName, callback) {
debug('Creating createSubcontainer for %s/%s : %s', app.id, taskName, gState[appId].schedulerConfig[taskName].command);
docker.createSubcontainer(app, [ '/bin/sh', '-c', gState[appId].schedulerConfig[taskName].command ], function (error, container) {
// NOTE: if you change container name here, fix addons.js to return correct container names
docker.createSubcontainer(app, app.id + '-' + taskName, [ '/bin/sh', '-c', gState[appId].schedulerConfig[taskName].command ], function (error, container) {
appState.containerIds[taskName] = container.id;
saveState(gState);
+32 -17
View File
@@ -10,6 +10,7 @@ exports = module.exports = {
var appdb = require('./appdb.js'),
assert = require('assert'),
child_process = require('child_process'),
cloudron = require('./cloudron.js'),
debug = require('debug')('box:taskmanager'),
locker = require('./locker.js'),
_ = require('underscore');
@@ -18,12 +19,41 @@ var gActiveTasks = { };
var gPendingTasks = [ ];
var TASK_CONCURRENCY = 5;
var NOOP_CALLBACK = function (error) { console.error(error); };
var NOOP_CALLBACK = function (error) { if (error) console.error(error); };
function initialize(callback) {
assert.strictEqual(typeof callback, 'function');
// resume app installs and uninstalls
locker.on('unlocked', startNextTask);
if (cloudron.isConfiguredSync()) {
resumeTasks();
} else {
cloudron.events.on(cloudron.EVENT_CONFIGURED, resumeTasks);
}
callback();
}
function uninitialize(callback) {
assert.strictEqual(typeof callback, 'function');
gPendingTasks = [ ]; // clear this first, otherwise stopAppTask will resume them
for (var appId in gActiveTasks) {
stopAppTask(appId);
}
cloudron.events.removeListener(cloudron.EVENT_CONFIGURED, resumeTasks);
locker.removeListener('unlocked', startNextTask);
callback(null);
}
// resume app installs and uninstalls
function resumeTasks(callback) {
callback = callback || NOOP_CALLBACK;
appdb.getAll(function (error, apps) {
if (error) return callback(error);
@@ -36,21 +66,6 @@ function initialize(callback) {
callback(null);
});
locker.on('unlocked', startNextTask);
}
function uninitialize(callback) {
assert.strictEqual(typeof callback, 'function');
gPendingTasks = [ ]; // clear this first, otherwise stopAppTask will resume them
for (var appId in gActiveTasks) {
stopAppTask(appId);
}
locker.removeListener('unlocked', startNextTask);
callback(null);
}
function startNextTask() {
+2 -2
View File
@@ -120,8 +120,8 @@
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand navbar-brand-icon" href="index.html"><img src="/api/v1/cloudron/avatar" width="40" height="40"/></a>
<a class="navbar-brand" href="index.html">Cloudron</a>
<a class="navbar-brand navbar-brand-icon" href="#/"><img src="/api/v1/cloudron/avatar" width="40" height="40"/></a>
<a class="navbar-brand" href="#/">Cloudron</a>
</div>
<!-- /.navbar-header -->