Skip to content

Commit

Permalink
Move to static page generation & deployment for frontend
Browse files Browse the repository at this point in the history
- New frontend/ directory for build process moved out of Django

- Reorganize gulpfile.js into separate modules for tasks

- Tweak addon build process to no longer move builds into Django static path

- Generate static landing pages with social share metadata

- Create server-side /api/experiments/usage_counts/json resource

- Generate static /api/experiments/usage_counts.json

- Static build & deploy machinery for Circle CI

Issue mozilla#1306, mozilla#1312, mozilla#1305
  • Loading branch information
lmorchard committed Sep 3, 2016
1 parent f8a611c commit b442f83
Show file tree
Hide file tree
Showing 20 changed files with 650 additions and 490 deletions.
1 change: 1 addition & 0 deletions .gitignore
@@ -1,3 +1,4 @@
frontend/build/
dist/
build/
tmp/
Expand Down
18 changes: 8 additions & 10 deletions addon/bin/sign
Expand Up @@ -36,8 +36,6 @@ var signedOpts = {
}
};

var staticProjectPath = path.resolve('../testpilot/frontend/static-src/addon/');

request(signedOpts, signCb);

function signCb(err, resp, body) {
Expand Down Expand Up @@ -68,15 +66,15 @@ function distAddon() {

// move our signed xpi and rdf into the testpilot static
// directory and exit
checkExistsAndMv(signedXpiPath, staticProjectPath + '/addon.xpi', function(err) {
checkExistsAndMv(signedXpiPath, 'addon.xpi', function(err) {
if (err) return console.error(err);
console.log('addon.xpi written to ' + staticProjectPath + '/addon.xpi');
console.log('addon.xpi written to addon.xpi');
});

var generatedRdf = '@testpilot-addon-' + version + '.update.rdf';
checkExistsAndMv(generatedRdf, staticProjectPath + '/update.rdf', function(err) {
checkExistsAndMv(generatedRdf, 'update.rdf', function(err) {
if (err) return console.error(err);
console.log('update.rdf written to ' + staticProjectPath + '/update.rdf');
console.log('update.rdf written to update.rdf');
});
});
});
Expand All @@ -98,14 +96,14 @@ function distAddonSigned() {

// move our signed xpi and rdf into the testpilot static
// directory and exit
checkExistsAndMv('addon.xpi', staticProjectPath + '/addon.xpi', function(err) {
checkExistsAndMv('addon.xpi', 'addon.xpi', function(err) {
if (err) return console.error(err);
console.log('addon.xpi written to ' + staticProjectPath + '/addon.xpi');
console.log('addon.xpi written to addon.xpi');
});

checkExistsAndMv(updateFile, staticProjectPath + '/update.rdf', function(err) {
checkExistsAndMv(updateFile, 'update.rdf', function(err) {
if (err) return console.error(err);
console.log('update.rdf written to ' + staticProjectPath + '/update.rdf');
console.log('update.rdf written to update.rdf');
});
});
}
Expand Down
4 changes: 2 additions & 2 deletions addon/package.json
Expand Up @@ -26,8 +26,8 @@
"watch-ui": "watchify-server ui-test.js --index ui-test.html",
"lint": "eslint .",
"sign": "./bin/update-version && ./bin/sign",
"package": "jpm xpi && mv testpilot-addon.xpi ../testpilot/frontend/static-src/addon/addon.xpi && mv @testpilot-addon-$npm_package_version.update.rdf ../testpilot/frontend/static-src/addon/update.rdf",
"lint-addon": "addons-linter ../testpilot/frontend/static-src/addon/addon.xpi -o text"
"package": "jpm xpi && mv testpilot-addon.xpi addon.xpi && mv @testpilot-addon-$npm_package_version.update.rdf update.rdf",
"lint-addon": "addons-linter addon.xpi -o text"
},
"license": "MPL-2.0",
"devDependencies": {
Expand Down
6 changes: 5 additions & 1 deletion bin/circleci/build-frontend.sh
@@ -1,5 +1,9 @@
#!/bin/bash
set -ex
npm install
npm run build
if [[ " $TESTPILOT_STATIC_BRANCHES " =~ " $CIRCLE_BRANCH " ]]; then
npm run static
else
npm run build
fi
npm test
39 changes: 24 additions & 15 deletions bin/circleci/run-integration-tests.sh
@@ -1,17 +1,22 @@
#!/bin/bash

# Fire up an instance of the site configured for integration testing
docker run \
--name integration \
--detach \
--net=host \
-p 127.0.0.1:8000:8000 \
-e 'DEBUG=False' \
-e 'SECRET_KEY=Foo' \
-e 'ALLOWED_HOSTS=testpilot.dev' \
-e 'DATABASE_URL=postgres://ubuntu@localhost/circle_test' \
app:build \
bin/circleci/run-integration-test-server.sh
if [[ " $TESTPILOT_STATIC_BRANCHES " =~ " $CIRCLE_BRANCH " ]]; then
npm run server &
STATIC_SERVER_PID=$!
else
# Fire up an instance of the site configured for integration testing
docker run \
--name integration \
--detach \
--net=host \
-p 127.0.0.1:8000:8000 \
-e 'DEBUG=False' \
-e 'SECRET_KEY=Foo' \
-e 'ALLOWED_HOSTS=testpilot.dev' \
-e 'DATABASE_URL=postgres://ubuntu@localhost/circle_test' \
app:build \
bin/circleci/run-integration-test-server.sh
fi

# Wait until the server is available...
until $(curl --output /dev/null --silent --head --fail http://testpilot.dev:8000); do
Expand All @@ -25,9 +30,13 @@ python integration/runtests.py \
integration
TEST_STATUS=$?

# Clean up - these commands will sometimes produce errors, but ignore them
docker kill integration
docker rm integration
if [[ " $TESTPILOT_STATIC_BRANCHES " =~ " $CIRCLE_BRANCH " ]]; then
kill $STATIC_SERVER_PID
else
# Clean up - these commands will sometimes produce errors, but ignore them
docker kill integration
docker rm integration
fi

# Report what happened
if [ $TEST_STATUS -eq 0 ]
Expand Down
19 changes: 16 additions & 3 deletions circle.yml
Expand Up @@ -12,9 +12,10 @@ machine:
testpilot.dev: 127.0.0.1
services:
- docker

node:
version: 6.2.0
environment:
TESTPILOT_STATIC_BRANCHES: development-static 1307-react-time-omg

dependencies:

Expand All @@ -37,11 +38,11 @@ dependencies:
- ./bin/circleci/build-version-json.sh
- ./bin/circleci/build-addon.sh
- ./bin/circleci/build-frontend.sh
- ./bin/circleci/build-server.sh
- '[[ " $TESTPILOT_STATIC_BRANCHES " =~ " $CIRCLE_BRANCH " ]] || ./bin/circleci/build-server.sh'

test:
override:
- ./bin/circleci/run-server-unit-tests.sh
- '[[ " $TESTPILOT_STATIC_BRANCHES " =~ " $CIRCLE_BRANCH " ]] || ./bin/circleci/run-server-unit-tests.sh'
- ./bin/circleci/run-integration-tests.sh

# appropriately tag and push the container to dockerhub
Expand All @@ -66,6 +67,18 @@ deployment:
- "docker images"
- "docker push ${DOCKERHUB_REPO}:${CIRCLE_TAG}"

static_development:
branch:
- development-static
commands:
- aws s3 sync dist s3://testpilot-static.dev.mozaws.net --delete --acl public-read

static_development_lorchard:
branch:
- 1307-react-time-omg
commands:
- aws s3 sync dist s3://testpilot-static.lorchard.mozaws.net --delete --acl public-read

# Only notify of builds on master branch.
experimental:
notify:
Expand Down
24 changes: 24 additions & 0 deletions frontend/config.js
@@ -0,0 +1,24 @@
module.exports = {
SERVER_PORT: 8000,
IS_DEBUG: (process.env.NODE_ENV === 'development'),

// TODO: Move addon build to a better path
ADDON_SRC_PATH: './addon/',

SRC_PATH: './frontend/src/',
DEST_PATH: './frontend/build/',
DIST_PATH: './dist/',
DJANGO_DIST_PATH: './testpilot/frontend/static/',
CONTENT_SRC_PATH: './content-src/',

PRODUCTION_EXPERIMENTS_URL: 'https://testpilot.firefox.com/api/experiments',
IMAGE_NEW_BASE_PATH: 'images/experiments/',
IMAGE_NEW_BASE_URL: '/static/images/experiments/',

'sass-lint': true,
'js-lint': true
};

// Pull in local debug-config.json overrides
const tryRequire = require('try-require');
Object.assign(module.exports, tryRequire('../debug-config.json') || {});
20 changes: 20 additions & 0 deletions frontend/tasks/assets.js
@@ -0,0 +1,20 @@
const gulp = require('gulp');
const config = require('../config.js');

gulp.task('assets-locales', () => gulp.src('./locales/**/*')
.pipe(gulp.dest(config.DEST_PATH + 'static/locales')));

gulp.task('assets-addon', () => gulp.src([
config.ADDON_SRC_PATH + 'addon.xpi',
config.ADDON_SRC_PATH + 'update.rdf'
]).pipe(gulp.dest(config.DEST_PATH + 'static/addon')));

gulp.task('assets-build', [
'assets-locales',
'assets-addon'
]);

gulp.task('assets-watch', () => {
gulp.watch(config.SRC_PATH + 'addon/**/*', ['assets-addon']);
gulp.watch('./locales/**/*', ['assets-locales']);
});
150 changes: 150 additions & 0 deletions frontend/tasks/content.js
@@ -0,0 +1,150 @@
const gulp = require('gulp');
const config = require('../config.js');

const fs = require('fs');
const mkdirp = require('mkdirp');
const through = require('through2');
const gutil = require('gulp-util');
const YAML = require('yamljs');

gulp.task('content-build', ['content-experiments-json']);

gulp.task('content-watch', () => {
gulp.watch(config.CONTENT_SRC_PATH + '/*.yaml', ['content-experiments-json']);
});

gulp.task('content-experiments-json', function generateStaticAPITask() {
return gulp.src(config.CONTENT_SRC_PATH + 'experiments/*.yaml')
.pipe(buildExperimentsJSON())
.pipe(gulp.dest(config.DEST_PATH + 'api'));
});

gulp.task('import-api-content', (done) => {
fetch(config.PRODUCTION_EXPERIMENTS_URL)
.then(response => response.json())
.then(data => Promise.all(data.results.map(processImportedExperiment)))
.then(() => done())
.catch(done);
});

function buildExperimentsJSON() {
const index = {results: []};
const counts = {};

function collectEntry(file, enc, cb) {
const yamlData = file.contents.toString();
const experiment = YAML.parse(yamlData);

// Auto-generate some derivative API values expected by the frontend.
Object.assign(experiment, {
url: `/api/experiments/${experiment.id}.json`,
html_url: `/experiments/${experiment.slug}`,
installations_url: `/api/experiments/${experiment.id}/installations/`,
survey_url: `https://qsurvey.mozilla.com/s3/${experiment.slug}`
});

counts[experiment.addon_id] = experiment.installation_count;
delete experiment.installation_count;

this.push(new gutil.File({
path: `experiments/${experiment.id}.json`,
contents: new Buffer(JSON.stringify(experiment, null, 2))
}));

index.results.push(experiment);

cb();
}

function endStream(cb) {
this.push(new gutil.File({
path: 'experiments.json',
contents: new Buffer(JSON.stringify(index, null, 2))
}));
this.push(new gutil.File({
path: 'experiments/usage_counts.json',
contents: new Buffer(JSON.stringify(counts, null, 2))
}));
cb();
}

return through.obj(collectEntry, endStream);
}

function processImportedExperiment(experiment) {
// Clean up auto-generated and unused model fields.
const fieldsToDelete = {
'': ['url', 'html_url', 'installations_url', 'survey_url'],
details: ['order', 'url', 'experiment_url'],
tour_steps: ['order', 'experiment_url'],
contributors: ['username']
};
Object.keys(fieldsToDelete).forEach(key => {
const items = (key === '') ? [experiment] : experiment[key];
const fields = fieldsToDelete[key];
items.forEach(item => fields.forEach(field => delete item[field]));
});

// Download all the images associated with the experiment.
const imageFields = {
'': ['thumbnail', 'image_twitter', 'image_facebook'],
details: ['image'],
tour_steps: ['image'],
contributors: ['avatar']
};
const toDownload = [];
Object.keys(imageFields).forEach(key => {
const items = (key === '') ? [experiment] : experiment[key];
const fields = imageFields[key];
items.forEach(item => fields.forEach(field => {
// Grab the original image URL, bail if it's not available
const origURL = item[field];
if (!origURL) { return; }

// Chop off the protocol & domain, convert gravatar param to .jpg
const path = origURL.split('/').slice(3).join('/').replace('?s=64', '.jpg');

// Now build a new file path and URL for the image
const newPath = `${config.IMAGE_NEW_BASE_PATH}${experiment.slug}/${path}`;
const newURL = `${config.IMAGE_NEW_BASE_URL}${experiment.slug}/${path}`;

// Replace the old URL with new static URL
item[field] = newURL;

// Schedule the old URL for download at the new path.
toDownload.push({url: origURL, path: newPath});
}));
});

// Download all the images, then write the YAML.
return Promise.all(toDownload.map(downloadURL))
.then(() => writeExperimentYAML(experiment));
}

// Write file contents after first ensuring the parent directory exists.
function writeFile(path, content) {
const parentDir = path.split('/').slice(0, -1).join('/');
return new Promise((resolve, reject) => {
mkdirp(parentDir, dirErr => {
if (dirErr) { return reject(dirErr); }
fs.writeFile(path, content, err => err ? reject(err) : resolve(path));
});
});
}

function downloadURL(item) {
const {url, path} = item;
return fetch(url)
.then(res => res.buffer())
.then(resBuffer => writeFile(path, resBuffer))
.then(() => {
if (config.IS_DEBUG) { console.log('Downloaded', url, 'to', path); }
});
}

function writeExperimentYAML(experiment) {
const out = YAML.stringify(experiment, 4, 2);
const path = `${config.CONTENT_SRC_PATH}experiments/${experiment.slug}.yaml`;
if (config.IS_DEBUG) { console.log(`Generated ${path}`); }
return writeFile(path, out);
}

0 comments on commit b442f83

Please sign in to comment.