Skip to content

Commit

Permalink
feat(plugins): able to load plugin stages into UI
Browse files Browse the repository at this point in the history
This adds the ability to add stages as plugins in the UI. There is an interface for plugin developers to use to create a UI for their stages. The stage UI can be built using ReactJS.

When Deck is starting up, it will look to see if any plugins are defined in `spinnakerSettings`. If there are plugins defined, a script tag will be appended to the bottom of the page and the plugin will have its initialized method called. The plugin initialize method takes in the `Registry` so the stage can register itself as a stage. Then the plugin developer has to call `window.spinnakerSettings.onPluginLoaded` with their plugin object.

We removed the `ng-app` from `index.deck` because of using `modules` in a promise. The bootstrapping of deck now happens in a separate Javascript file `app/scripts/bootstrap.js`. This script is added to the page via webpack. The reason for this change was tests were failing when calling `bootstrap`. `bootstrap` only happened before when loading the page by hitting `index.deck`. The tests were expecting things to not be loaded, but with `bootstrap` the entire application was loaded.

When loading plugins we didn't use CommonJS or ES modules due to keeping the list of supported browsers larger.
  • Loading branch information
Brandon Powell authored and mergify[bot] committed Dec 20, 2019
1 parent 225046c commit ba56a2a
Show file tree
Hide file tree
Showing 15 changed files with 240 additions and 0 deletions.
7 changes: 7 additions & 0 deletions app/scripts/app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'jquery'; // ensures jQuery is loaded before Angular so Angular does not use jqlite
import { module } from 'angular';
import './strictDi';
import { initPlugins } from './plugin-loader';

import { CORE_MODULE } from '@spinnaker/core';
import { DOCKER_MODULE } from '@spinnaker/docker';
Expand All @@ -18,6 +19,11 @@ import { AZURE_MODULE } from '@spinnaker/azure';
import { HUAWEICLOUD_MODULE } from '@spinnaker/huaweicloud';
import { DCOS_DCOS_MODULE } from './modules/dcos/dcos.module';

initPlugins()
.catch(() => {
//TODO use CustomBanner to tell the user that plugin(s) have not been loaded
})
.finally(() => {
module('netflix.spinnaker', [
CORE_MODULE,
AMAZON_MODULE,
Expand All @@ -35,3 +41,4 @@ module('netflix.spinnaker', [
TITUS_MODULE,
HUAWEICLOUD_MODULE,
]);
});
5 changes: 5 additions & 0 deletions app/scripts/bootstrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { bootstrap, element } from 'angular';

element(document.documentElement).ready(() => {
bootstrap(document.documentElement, ['netflix.spinnaker']);
});
1 change: 1 addition & 0 deletions app/scripts/modules/core/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export interface ISpinnakerSettings {
providers?: {
[key: string]: IProviderSettings; // allows custom providers not typed in here (good for testing too)
};
plugins: Array<{ name: string; location: string }>;
pubsubProviders: string[];
quietPeriod: [string | number, string | number];
resetProvider: (provider: string) => () => void;
Expand Down
5 changes: 5 additions & 0 deletions app/scripts/modules/plugins/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions app/scripts/modules/plugins/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@spinnaker/plugins",
"version": "0.0.1",
"main": "lib/lib.js",
"types": "lib/plugins.d.ts",
"scripts": {
"clean": "../../../../node_modules/rimraf/bin.js lib",
"lib": "npm run clean && ../../../../node_modules/typescript/bin/tsc && node ../../../../node_modules/webpack/bin/webpack.js",
"prepublishOnly": "npm run lib"
},
"dependencies": {
"@spinnaker/core": "^0.0.406"
}
}
2 changes: 2 additions & 0 deletions app/scripts/modules/plugins/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// @ts-ignore
export * from './plugins';
1 change: 1 addition & 0 deletions app/scripts/modules/plugins/src/plugins.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module '@spinnaker/plugins';
13 changes: 13 additions & 0 deletions app/scripts/modules/plugins/src/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { PipelineRegistry } from '@spinnaker/core';

export type IPluginInitialize = (registry: IStageRegistry) => void;

export interface IStageRegistry {
pipeline: PipelineRegistry;
}

declare global {
interface Window {
spinnakerSettings: any;
}
}
38 changes: 38 additions & 0 deletions app/scripts/modules/plugins/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"buildOnSave": false,
"compileOnSave": true,
"compilerOptions": {
"allowJs": false,
"baseUrl": "./src",
"declaration": true,
"declarationDir": "lib",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"jsx": "react",
"lib": ["es2016", "dom"],
"moduleResolution": "node",
"module": "esnext",
"noEmitHelpers": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": false, // should really get to a place where we can turn this on
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "lib",
"pretty": true,
"removeComments": false,
"rootDir": "./src",
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
"strictNullChecks": false, // should really get to a place where we can turn this on
"target": "es6",
"typeRoots": ["../../../../node_modules/@types"],
"paths": {
"@spinnaker/core": ["../../core/lib"],
"core/*": ["core/src/*"]
}
},
"files": ["src/index.ts"],
"exclude": ["./lib", "**/*.spec.*"]
}
114 changes: 114 additions & 0 deletions app/scripts/modules/plugins/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use strict';

const path = require('path');
const basePath = path.join(__dirname, '..', '..', '..', '..');
const NODE_MODULE_PATH = path.join(basePath, 'node_modules');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const nodeExternals = require('webpack-node-externals');
const TerserPlugin = require('terser-webpack-plugin');
const exclusionPattern = /(node_modules|\.\.\/deck)/;
const WEBPACK_THREADS = Math.max(require('physical-cpu-count') - 1, 1);

const WATCH = process.env.WATCH === 'true';
const WEBPACK_MODE = WATCH ? 'development' : 'production';
const IS_PRODUCTION = WEBPACK_MODE === 'production';

module.exports = {
context: basePath,
mode: WEBPACK_MODE,
stats: 'minimal',
watch: WATCH,
entry: {
lib: path.join(__dirname, 'src', 'index.ts'),
},
output: {
path: path.join(__dirname, 'lib'),
filename: '[name].js',
library: '@spinnaker/plugins',
libraryTarget: 'umd',
umdNamedDefine: true,
},
devtool: 'source-map',
optimization: {
minimizer: IS_PRODUCTION
? [
new TerserPlugin({
cache: true,
parallel: true,
sourceMap: true,
terserOptions: {
ecma: 6,
mangle: false,
output: {
comments: false,
},
},
}),
]
: [], // disable minification in development mode
},
resolve: {
extensions: ['.json', '.js', '.jsx', '.ts', '.tsx', '.css', '.less', '.html'],
modules: [NODE_MODULE_PATH, path.resolve('.')],
alias: {
'@spinnaker/plugins': path.join(__dirname, 'src'),
plugins: path.join(__dirname, 'src'),
},
},
module: {
rules: [
{
test: /\.js$/,
use: [
{ loader: 'cache-loader' },
{ loader: 'thread-loader', options: { workers: WEBPACK_THREADS } },
{ loader: 'babel-loader' },
{ loader: 'envify-loader' },
{ loader: 'eslint-loader' },
],
exclude: exclusionPattern,
},
{
test: /\.tsx?$/,
use: [
{ loader: 'cache-loader' },
{ loader: 'thread-loader', options: { workers: WEBPACK_THREADS } },
{ loader: 'ts-loader', options: { happyPackMode: true } },
{ loader: 'tslint-loader' },
],
exclude: exclusionPattern,
},
{
test: /\.less$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
{ loader: 'postcss-loader' },
{ loader: 'less-loader' },
],
},
{
test: /\.css$/,
use: [{ loader: 'style-loader' }, { loader: 'css-loader' }, { loader: 'postcss-loader' }],
},
{
test: /\.html$/,
exclude: exclusionPattern,
use: [
{ loader: 'ngtemplate-loader?relativeTo=' + path.resolve(__dirname) + '&prefix=plugins' },
{ loader: 'html-loader' },
],
},
{
test: /\.(woff|woff2|otf|ttf|eot|png|gif|ico|svg)$/,
use: [{ loader: 'file-loader', options: { name: '[name].[hash:5].[ext]' } }],
},
{
test: require.resolve('jquery'),
use: [{ loader: 'expose-loader?$' }, { loader: 'expose-loader?jQuery' }],
},
],
},
plugins: [new ForkTsCheckerWebpackPlugin({ checkSyntacticErrors: true })],
externals: ['@spinnaker/core', nodeExternals({ modulesDir: '../../../../node_modules' })],
};
8 changes: 8 additions & 0 deletions app/scripts/modules/plugins/yarn.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


"@spinnaker/core@^0.0.406":
version "0.0.406"
resolved "https://registry.yarnpkg.com/@spinnaker/core/-/core-0.0.406.tgz#898f555a43b401f44561e8a742a92e51f128a476"
integrity sha512-NTqryXCEpYL8feO4hMZyBH70gVBfVabyJ4nChgmIC8PlZ/TMCN8rpGOtr9Q9g/tqgMUXRYiGWnNqxRS/g2Zs4g==
29 changes: 29 additions & 0 deletions app/scripts/plugin-loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Registry } from '@spinnaker/core';

// Appends plugin resources to the bottom of the page via a script
// tag. This makes it so the plugins start loading after the Spinnaker
// application is loaded.
function loadPluginScript(plugin) {
return new Promise((resolve, reject) => {
var scriptTag = document.createElement('script');
scriptTag.src = plugin.location;
scriptTag.onload = () => resolve();
scriptTag.onreadystatechange = () => resolve();
scriptTag.onerror = () => reject();
document.body.appendChild(scriptTag);
});
}

// This method grabs all plugins that are defined in Spinnaker settings
// and will call their initialize method and append the script location
// to the bottom of the page. The initialize function is based on the
// interface defined in the plugins module. The Registry is passed into
// the initialize method so the plugin can register itself as a stage.
// This is done by calling window.spinnakerSettings.onPluginLoaded and
// the plugin developer passes in their plugin object that contains
// the initialize method.
export function initPlugins() {
const plugins = window.spinnakerSettings.plugins;
window.spinnakerSettings.onPluginLoaded = plugin => plugin.initialize(Registry);
return Promise.all(plugins.map(p => loadPluginScript(p)));
}
1 change: 1 addition & 0 deletions halconfig/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ window.spinnakerSettings = {
},
},
pubsubProviders: ['google'], // TODO(joonlim): Add amazon once it is confirmed that amazon pub/sub works.
plugins: '{{%plugins%}}',
triggerTypes: [
'artifactory',
'nexus',
Expand Down
1 change: 1 addition & 0 deletions settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ window.spinnakerSettings = {
baseUrl: 'https://slack.com',
},
pubsubProviders: ['google'], // TODO(joonlim): Add amazon once it is confirmed that amazon pub/sub works.
plugins: [],
searchVersion: 1,
triggerTypes: [
'artifactory',
Expand Down
1 change: 1 addition & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function configure(env, webpackOpts) {
settings: SETTINGS_PATH,
'settings-local': './settings-local.js',
app: './app/scripts/app.ts',
bootstrap: './app/scripts/bootstrap.js',
},
output: {
path: path.join(__dirname, 'build', 'webpack', process.env.SPINNAKER_ENV || ''),
Expand Down

0 comments on commit ba56a2a

Please sign in to comment.