Yogu/site-manager

View on GitHub
src/tasks/backup.js

Summary

Maintainability
A
3 hrs
Test Coverage
var Task = require('../task.js');
var fs = require('q-io/fs');
var ShellTask = require('./shell.js');
var Q = require('q');
var hooks = require('../hooks.js');
var yaml = require('js-yaml');
require('colors');

/**
  * Makes a backup of this site
    */
function BackupTask(site, message, options) {
    Task.call(this);
    this.site = site;
    this.name = 'Backup (' + message + ')';
    this.message = message;
    this.options = options || {};
}

BackupTask.prototype = Object.create(Task.prototype);

BackupTask.prototype.perform = function*() {
    var site = this.site;
    var dataPath = fs.join(site.path, 'data');

    if (!(yield fs.exists(dataPath + '/.git')))
        yield this.runNested(new InitDataDirectoryTask(site));
    yield hooks.call('beforeBackup', this, site);

    // store info
    this.doLog('Writing backup.yaml...');
    var info = {
        revision: site.revision
    };
    yield fs.write(dataPath + '/backup.yaml', yaml.safeDump(info));

    this.doLog('Committing backup...');
    this.cd(dataPath);
    yield this.exec('git add -A');
    yield this.exec('git commit --allow-empty -m ' + ShellTask.escape(this.message));
    yield pushIfRemoteExists(this, site.siteManager.backupRepoPath, "refs/heads/" + site.name);

    var revision = (yield this.execQuietly("git rev-parse HEAD")).stdout.trim();
    this.doLog('Backup succeeded'.green);
    this.doLog('backup id: ' + revision);

    yield this.runNestedQuietly(site.loadTask());

    site.emit('backup');

    return revision;
};

/**
 * Restores a backup
 * @param revision the revision of the backup to restore
 * @option backup set to false to skip the backup before restore
 */
function RestoreTask(site, revision, options) {
    Task.call(this);
    this.site = site;
    this.name = 'Restore backup ' + revision;
    this.revision = revision;
    this.options = options || {};
}

RestoreTask.prototype = Object.create(Task.prototype);

RestoreTask.prototype.perform = function*() {
    var site = this.site;
    var dataPath = fs.join(site.path, 'data');

    if (!(yield fs.exists(dataPath + '/.git')))
        yield this.runNested(new InitDataDirectoryTask(site));
    this.cd(dataPath);

    // To be safe, backup first
    if (this.options.backup !== false) {
        yield this.runNested(new BackupTask(site, 'pre-restore ' + this.revision));
    } else {
        this.doLog('Skipping pre-restore backup');
    }

    // Add a tag so that the old revision can be reached (version-sort lists test.10 after test.9)
    var lastTag = (yield this.execQuietly('git tag -l "' + site.name + '.*" | sort --version-sort | tail -n 1')).stdout.trim();
    var lastTagNumber = 0;
    if (lastTag) {
        var lastTagNumber = parseInt(lastTag.substr(lastTag.indexOf('.') + 1));
        if (isNaN(lastTagNumber))
            lastTagNumber = 0;
    }
    var tagName = site.name + '.' + (lastTagNumber + 1);
    yield this.execQuietly('git tag ' + tagName);
    yield pushIfRemoteExists(this, site.siteManager.backupRepoPath, "refs/tags/" + tagName);

    this.doLog('Restoring data directory...');
    yield this.exec('git reset --hard ' + ShellTask.escape(this.revision));

    this.doLog('Restoring repository...');
    var info = yaml.safeLoad(yield fs.read(dataPath + '/backup.yaml'))
    this.cd(site.path);
    yield this.exec('git reset --hard ' + ShellTask.escape(info.revision));

    yield hooks.call('afterRestore', this, site);
    yield hooks.call('afterCheckout', this, site);

    this.doLog('Backup restored'.green);

    yield this.runNestedQuietly(site.loadTask());

    site.emit('restore');
};

function InitDataDirectoryTask(site) {
    Task.call(this);
    this.name = 'Initialize data directory for ' + site.name;
    this.site = site;
}

InitDataDirectoryTask.prototype = Object.create(Task.prototype);

InitDataDirectoryTask.prototype.perform = function*() {
    var site = this.site;
    var dataPath = fs.join(site.path, 'data');
    this.cd(site.path);
    this.doLog('data does not exist or is not a git repository, initializing it...'.yellow.bold);
    yield this.exec('git init data');
    this.cd(dataPath);
    // switch to the correct branch, but leave that branch empty (do not derive from master)
    yield this.exec('git symbolic-ref HEAD ' + ShellTask.escape("refs/heads/" + site.name));
    // symlink, many things, similar to git-new-workdir, but so that it works with a bare
    // root repo
    var symlinks = [ 'logs/refs/', 'objects/', 'packed-refs', 'refs/' ];
    var backupRepoPath = site.siteManager.backupRepoPath;

    if (!(yield fs.exists(fs.join(dataPath, '.git/logs'))))
        yield fs.makeDirectory(fs.join(dataPath, '.git/logs'));

    for (var i = 0; i < symlinks.length; i++) {
        var name = symlinks[i];
        var linkPath = fs.join(dataPath, '.git', name);
        var relativeBackupRepoPath = yield fs.relative(fs.directory(linkPath), backupRepoPath)
        var targetPath = fs.join(relativeBackupRepoPath, name);
        if (yield fs.exists(linkPath))
            yield fs.removeTree(linkPath);
        var isDir = name[name.length - 1] == '/';
        yield fs.symbolicLink(linkPath, targetPath, isDir ? 'file' : 'directory');
    }
};

exports.getBackups = Q.async(function*(site) {
    if (!(yield fs.exists(site.path + '/data/.git')))
        return []; // no data, so no backups

    var currentBackupRevision = yield exports.getCurrentBackupRevision(site);
    if (!currentBackupRevision)
        return [];

    var result = yield ShellTask.exec('git log --tags="' + site.name + '.*" --graph ' +
            '--pretty=format:"%x09%H%x09%at%x09%P%x09%s" ' + site.name /* match the branch */,
            site.path + '/data');
    return result.stdout.split('\n').map(function(line) {
        var parts = line.split(/\t/);
        if (parts.length <= 1) {
            if (line != '')
                return {
                    type: 'guide',
                    prefix: line
                };
            else
                return null;
        }

        return {
            type: 'backup',
            prefix: parts[0],
            revision: parts[1],
            time: new Date(parts[2] * 1000),
            parentRevision: parts[3],
            message: parts[4],
            isCurrent: parts[1] == currentBackupRevision
        };
    })
    .filter(function(v) { return v; }); // remove null entries
});

exports.getCurrentBackupRevision = Q.async(function*(site) {
    try {
        var result = yield ShellTask.exec('git rev-parse ' + ShellTask.escape('refs/heads/' + site.name),
                site.path + '/data');
        return result.stdout.trim();
    } catch(err) {
        if (typeof err == 'object' && err.code == 128)
            return null; // the branch does not exist (e.g. unborn)
        throw err;
    }
});

exports.getBackup = Q.async(function*(site, revision) {
    if (!(yield fs.exists(site.path + '/data/.git')))
        return []; // no data, so no backups

    var dataPath = site.path + '/data';

    var result = yield ShellTask.exec('git log -n 1 ' +
            '--pretty=format:"%x09%H%x09%at%x09%P%x09%s" ' + ShellTask.escape(revision),
            dataPath);
    var lines = result.stdout.split('\n').filter(function(line) { return line.trim(); });
    if (!lines.length)
        return null; // not found

    var parts = lines[0].split(/\t/);
    var backup = {
        revision: parts[1],
        time: new Date(parts[2] * 1000),
        parentRevision: parts[3],
        message: parts[4]
    };

    result = yield ShellTask.exec("git show " + ShellTask.escape(revision + ':backup.yaml'), dataPath);
    var info = yaml.safeLoad(result.stdout);
    backup.siteRevision = info.revision;

    return backup;
});

var pushIfRemoteExists = Q.async(function*(task, path, committish) {
    // force push because when restoring, the branch is reset
    yield task.exec("if [ `git remote | wc -l` -gt 0 ] ; then git push origin -f " + committish + ":" + committish + " ; fi", path);
});

exports.BackupTask = BackupTask;
exports.RestoreTask = RestoreTask;