diff --git a/.eslintrc.js b/.eslintrc.js index bf252b064..6e46e9ce6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,13 +32,7 @@ module.exports = { "capitalized-comments": "off", "class-methods-use-this": "warn", "comma-dangle": "off", - "comma-spacing": [ - "error", - { - "after": true, - "before": false - } - ], + "comma-spacing": "off", "comma-style": [ "error", "last" diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 39c020ccb..6720f25a7 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -284,7 +284,7 @@ "@tradle/models-cloud": { "version": "1.0.0", "from": "tradle/models-cloud", - "resolved": "git://github.com/tradle/models-cloud.git#27f3c9784d54df3b8fbe8306e4f0ec2ce227c5be" + "resolved": "git://github.com/tradle/models-cloud.git#9562fce2d8369365438f40df768aee6dc7e56da9" }, "@tradle/models-corporate-onboarding": { "version": "1.0.0", diff --git a/src/aws.ts b/src/aws.ts index 99a5aadb2..1cd57eeb7 100644 --- a/src/aws.ts +++ b/src/aws.ts @@ -104,7 +104,7 @@ export default function createAWSWrapper ({ env, logger }: { ...conf }) } else { - if (env.TESTING && !services[lServiceName]) { + if (env.TESTING && !services[lServiceName] && lServiceName !== 'iot') { // don't pretend to support it as this will result // in calling the remote service! return null diff --git a/src/in-house-bot/commander.ts b/src/in-house-bot/commander.ts index c4a3e40a3..8be12c0ae 100644 --- a/src/in-house-bot/commander.ts +++ b/src/in-house-bot/commander.ts @@ -85,6 +85,7 @@ export const SUDO_ONLY_COMMANDS = [ 'doctor', 'balance', 'reindex', + 'updatestack', // 'encryptbucket', // 'enablebinary' ] diff --git a/src/in-house-bot/commands/index.ts b/src/in-house-bot/commands/index.ts index b66df4df2..e1ac3f477 100644 --- a/src/in-house-bot/commands/index.ts +++ b/src/in-house-bot/commands/index.ts @@ -24,6 +24,7 @@ import { command as setenvvar } from './setenvvar' import { command as doctor } from './doctor' import { command as balance } from './balance' import { command as reindex } from './reindex' +import { command as updatestack } from './updatestack' export { help, @@ -52,4 +53,5 @@ export { doctor, balance, reindex, + updatestack, } diff --git a/src/in-house-bot/commands/updatestack.ts b/src/in-house-bot/commands/updatestack.ts new file mode 100644 index 000000000..c74dc686b --- /dev/null +++ b/src/in-house-bot/commands/updatestack.ts @@ -0,0 +1,24 @@ +import { ICommand, IDeploymentConf } from '../types' + +export const command:ICommand = { + name: 'updatestack', + description: 'get a link to update your MyCloud', + examples: [ + '/updatestack --template-url ""', + '/updatestack --template-url "" --notification-topics ""', + ], + exec: async ({ commander, req, ctx, args }) => { + if (!ctx.sudo) throw new Error('forbidden') + + const { deployment } = commander + if (!deployment) { + throw new Error('"deployment" plugin not configured. Please add to plugins in bot.json') + } + + const { templateUrl, notificationTopics } = args + await deployment.updateOwnStack({ + templateUrl, + notificationTopics: notificationTopics.split(',').map(s => s.trim()) + }) + } +} diff --git a/src/in-house-bot/deployment.ts b/src/in-house-bot/deployment.ts index d5a607061..c13275b8a 100644 --- a/src/in-house-bot/deployment.ts +++ b/src/in-house-bot/deployment.ts @@ -37,9 +37,12 @@ const TMP_SNS_TOPIC_TTL = unitToMillis.day const LAUNCH_MESSAGE = 'Launch your Tradle MyCloud' const ONLINE_MESSAGE = 'Your Tradle MyCloud is online!' const CHILD_DEPLOYMENT = 'tradle.cloud.ChildDeployment' +const PARENT_DEPLOYMENT = 'tradle.cloud.ParentDeployment' const CONFIGURATION = 'tradle.cloud.Configuration' const AWS_REGION = 'tradle.cloud.AWSRegion' const TMP_SNS_TOPIC = 'tradle.cloud.TmpSNSTopic' +const UPDATE_REQUEST = 'tradle.cloud.UpdateRequest' +const UPDATE_RESPONSE = 'tradle.cloud.UpdateResponse' const UPDATE_REQUEST_TTL = 10 * unitToMillis.minute const DEFAULT_LAUNCH_TEMPLATE_OPTS = { template: 'action', @@ -256,10 +259,10 @@ export class Deployment { return { template, url: utils.getUpdateStackUrl({ stackId, templateUrl: url }), - snsTopic: await this.setupNotificationsForStack({ + snsTopic: (await this.setupNotificationsForStack({ id: `${accountId}-${name}`, type: StackOperationType.update - }) + })).topic } } @@ -298,16 +301,42 @@ export class Deployment { }) } + public getParentDeployment = async (): Promise => { + return await this.bot.db.findOne({ + orderBy: { + property: '_time', + desc: true + }, + filter: { + EQ: { + [TYPE]: CONFIGURATION, + 'childIdentity._permalink': await this.bot.getMyPermalink() + } + } + }) + } + public reportLaunch = async ({ org, identity, referrerUrl, deploymentUUID }: { org: IOrganization identity: IIdentity referrerUrl: string deploymentUUID: string }) => { + let saveParentDeployment try { - await utils.runWithTimeout(() => this.bot.friends.load({ url: referrerUrl }), { millis: 10000 }) + const friend = await utils.runWithTimeout( + () => this.bot.friends.load({ url: referrerUrl }), + { millis: 20000 } + ) + + saveParentDeployment = this.saveParentDeployment({ + friend, + apiUrl: referrerUrl, + childIdentity: identity + }) } catch (err) { this.logger.error('failed to add referring MyCloud as friend', err) + saveParentDeployment = Promise.resolve() } const reportLaunchUrl = this.getReportLaunchUrl(referrerUrl) @@ -325,6 +354,8 @@ export class Deployment { Errors.rethrow(err, 'developer') this.logger.error(`failed to notify referrer at: ${referrerUrl}`, err) } + + await saveParentDeployment } public receiveLaunchReport = async (report: ILaunchReportPayload) => { @@ -347,9 +378,9 @@ export class Deployment { }) await this.bot.draft({ - type: CHILD_DEPLOYMENT, - resource: childDeployment - }) + type: CHILD_DEPLOYMENT, + resource: childDeployment + }) .set({ apiUrl, identity: friend.identity, @@ -374,6 +405,21 @@ export class Deployment { return resource.toJSON() } + public saveParentDeployment = async ({ friend, childIdentity, apiUrl }: { + friend: ITradleObject + childIdentity: ITradleObject + apiUrl: string + }) => { + return await this.bot.draft({ type: PARENT_DEPLOYMENT }) + .set({ + childIdentity, + parentIdentity: friend.identity, + friend, + apiUrl + }) + .signAndSave() + } + public notifyConfigurer = async ({ configurer, links }: { links: IAppLinkSet configurer: string @@ -837,60 +883,81 @@ ${this.genUsageInstructions(links)}` }) } - public requestUpdate = async ({ friend }: { - friend: ITradleObject - }) => { - const { bot } = this - const { env } = bot - const updateReq = bot.draft({ type: 'tradle.cloud.UpdateRequest' }) + public requestUpdate = async () => { + const parent = await this.getParentDeployment() + return this.requestUpdateFromParent(parent) + } + + public requestUpdateFromParent = async (parent: ITradleObject) => { + const updateReq = this.createUpdateRequestResource(parent) + await this.bot.send({ + to: parent.parentIdentity._permalink, + object: updateReq + }) + } + + public createUpdateRequestResource = (parent: ITradleObject) => { + if (parent[TYPE] !== PARENT_DEPLOYMENT) { + throw new Errors.InvalidInput(`expected "parent" to be tradle.MyCloudFriend`) + } + + const { parentIdentity } = parent + const { env } = this.bot + return this.bot.draft({ type: UPDATE_REQUEST }) .set({ service: env.SERVERLESS_SERVICE_NAME, stage: env.SERVERLESS_STAGE, region: env.AWS_REGION, - provider: friend.identity, + provider: parentIdentity, }) .toJSON() - - await bot.send({ - to: friend.identity, - object: updateReq - }) } - public handleUpdateResponse = async (updateResponse: ITradleObject) => { - let req: ITradleObject - try { - req = await this.lookupUpdateRequest(updateResponse._author) - } catch (err) { - Errors.ignoreNotFound(err) - this.logger.warn('received stack update response...but no request was made, ignoring', { - from: updateResponse._author, - updateResponse: this.bot.buildStub(updateResponse) - }) - - throw err + public handleUpdateRequest = async ({ req, from }: { + req: ITradleObject + from: ITradleObject + }) => { + if (req._author !== buildResource.permalink(from)) { + throw new Errors.InvalidAuthor(`expected update request author to be the same identity as "from"`) } - if (req._time + UPDATE_REQUEST_TTL > Date.now()) { - const msg = 'received update response for expired request, ignoring' - this.logger.warn(msg, { - from: updateResponse._author, - updateResponse: this.bot.buildStub(updateResponse) - }) + const pkg = await this.genUpdatePackage({ + createdBy: req._author + }) - throw new Errors.Expired(msg) - } + const { snsTopic, url } = pkg + await this.bot.send({ + to: req._author, + object: await this.bot.draft({ type: UPDATE_RESPONSE }) + .set({ + templateUrl: url, + notificationTopics: snsTopic, + request: req, + provider: from + }) + .sign() + .then(r => r.toJSON()) + }) + return pkg + } + + public handleUpdateResponse = async (updateResponse: ITradleObject) => { + await this._validateUpdateResponse(updateResponse) const { templateUrl, notificationTopics } = updateResponse await this.bot.lambdaUtils.invoke({ name: 'cli', - arg: `--template-url "${templateUrl}" --notification-topics "${notificationTopics.join(',')}"`, - // don't wait + arg: `--template-url "${templateUrl}" --notification-topics "${notificationTopics}"`, + // don't wait for this to finish sync: false }) } public lookupUpdateRequest = async (providerPermalink: string) => { + if (!(typeof providerPermalink === 'string' && providerPermalink)) { + throw new Errors.InvalidInput('expected provider permalink') + } + return await this.bot.db.findOne({ orderBy: { property: '_time', @@ -898,7 +965,7 @@ ${this.genUsageInstructions(links)}` }, filter: { EQ: { - [TYPE]: 'tradle.cloud.UpdateRequest', + [TYPE]: UPDATE_REQUEST, 'provider._permalink': providerPermalink } } @@ -926,6 +993,34 @@ ${this.genUsageInstructions(links)}` logger: bot.logger }) } + + private _validateUpdateResponse = async (updateResponse: ITradleObject) => { + const provider = updateResponse._author + + let req: ITradleObject + try { + req = await this.lookupUpdateRequest(provider) + } catch (err) { + Errors.ignoreNotFound(err) + this.logger.warn('received stack update response...but no request was made, ignoring', { + from: provider, + updateResponse: this.bot.buildStub(updateResponse) + }) + + throw err + } + + if (req._time + UPDATE_REQUEST_TTL < Date.now()) { + const msg = 'received update response for expired request, ignoring' + this.logger.warn(msg, { + from: provider, + updateResponse: this.bot.buildStub(updateResponse) + }) + + throw new Errors.Expired(msg) + } + } + } export const createDeployment = (opts:DeploymentCtorOpts) => new Deployment(opts) diff --git a/src/in-house-bot/plugins/deployment.ts b/src/in-house-bot/plugins/deployment.ts index 9dfc03c87..94df09c42 100644 --- a/src/in-house-bot/plugins/deployment.ts +++ b/src/in-house-bot/plugins/deployment.ts @@ -12,6 +12,7 @@ import { IDeploymentConf, IDeploymentPluginConf, ITradleObject, + IPBReq, Conf } from '../types' @@ -124,7 +125,12 @@ export const createPlugin:CreatePlugin = (components, { conf, logger return { api: deployment, plugin: { - onFormsCollected + onFormsCollected, + 'onmessage:tradle.cloud.UpdateRequest': (req: IPBReq) => deployment.handleUpdateRequest({ + req: req.payload, + from: req.user + }), + 'onmessage:tradle.cloud.UpdateResponse': (req: IPBReq) => deployment.handleUpdateResponse(req.payload), } } } diff --git a/src/s3-utils.ts b/src/s3-utils.ts index 3cfa48c92..2ec3af631 100644 --- a/src/s3-utils.ts +++ b/src/s3-utils.ts @@ -27,7 +27,7 @@ export default class S3Utils { } public get publicFacingHost() { - return this.env.S3_PUBLIC_FACING_HOST + return this.env && this.env.TESTING && this.env.S3_PUBLIC_FACING_HOST } private get replicationAvailable() { @@ -37,11 +37,11 @@ export default class S3Utils { private get iamAvailable() { // localstack doesn't have IAM - return !this.env.TESTING + return this.env && !this.env.TESTING } private get versioningAvailable() { - return !this.env.TESTING + return this.env && !this.env.TESTING } public put = async ({ key, value, bucket, headers = {}, publicRead }: BucketPutOpts): Promise => { @@ -289,7 +289,7 @@ export default class S3Utils { Key: key }) - if (this.env && this.env.TESTING && this.publicFacingHost) { + if (this.publicFacingHost) { return url.replace(this.s3.config.endpoint, this.publicFacingHost) } diff --git a/src/test/in-house-bot/deployment.test.ts b/src/test/in-house-bot/deployment.test.ts index c20481466..f36f10f3d 100644 --- a/src/test/in-house-bot/deployment.test.ts +++ b/src/test/in-house-bot/deployment.test.ts @@ -16,6 +16,7 @@ import models from '../../models' import { IMyDeploymentConf, IBotConf, ILaunchReportPayload, IConf } from '../../in-house-bot/types' import { createTestEnv } from '../env' import { S3Utils } from '../../s3-utils' +import parseArgs from 'yargs-parser' const users = require('../fixtures/users.json') const { loudAsync } = utils @@ -69,10 +70,17 @@ test('deployment by referral', loudAsync(async (t) => { logger: child.logger.sub('deployment:test:child') }) - const childIdentity = await child.getMyIdentity() + const [ + childIdentity, + parentIdentity + ] = await Promise.all([ + child.getMyIdentity(), + parent.getMyIdentity() + ]) + // const kv = {} sandbox.stub(parent, 'getPermalink').resolves('abc') - const sendStub = sandbox.stub(parent, 'send').resolves({}) + const parentSendStub = sandbox.stub(parent, 'send').resolves({}) // sandbox.stub(parentDeployment.kv, 'put').callsFake(async (key, value) => { // t.equal(value.link, conf._link) @@ -208,6 +216,12 @@ test('deployment by referral', loudAsync(async (t) => { const childLoadFriendStub = sandbox.stub(child.friends, 'load').callsFake(async ({ url }) => { t.equal(url, parent.apiBaseUrl) + return { + _permalink: 'abc', + _link: 'abc', + [TYPE]: 'tradle.MyCloudFriend', + identity: buildResource.stub({ resource: parentIdentity }) + } }) const parentAddFriendStub = sandbox.stub(parent.friends, 'add').callsFake(async ({ url }) => { @@ -319,7 +333,7 @@ test('deployment by referral', loudAsync(async (t) => { t.equal(postStub.callCount, 1) t.same(sentEmails.sort(), [conf.adminEmail, conf.hrEmail].sort()) - t.equal(sendStub.getCall(0).args[0].to.id, conf._author) + t.equal(parentSendStub.getCall(0).args[0].to.id, conf._author) t.equal(parentAddFriendStub.callCount, 1) t.equal(childLoadFriendStub.callCount, 1) @@ -344,10 +358,33 @@ test('deployment by referral', loudAsync(async (t) => { saveResourceStub.reset() - const { url } = await parentDeployment.genUpdatePackage({ - createdBy: childIdentity._permalink + const updateReq = await child.sign(childDeployment.createUpdateRequestResource({ + [TYPE]: 'tradle.cloud.ParentDeployment', + parentIdentity: child.buildStub(parentIdentity), + childIdentity: child.buildStub(childIdentity), + })) + + let updateResponse + const { url } = await parentDeployment.handleUpdateRequest({ + from: childIdentity, + req: updateReq + }) + + updateResponse = getLastCallArgs(parentSendStub).object + t.equal(updateResponse[TYPE], 'tradle.cloud.UpdateResponse') + + const stubInvoke = sandbox.stub(child.lambdaUtils, 'invoke').callsFake(async ({ name, arg }) => { + t.equal(name, 'cli') + t.equal(parseArgs(arg).templateUrl, url) }) + const stubLookupRequest = sandbox.stub(childDeployment, 'lookupUpdateRequest').resolves(updateReq) + await childDeployment.handleUpdateResponse(updateResponse) + + // const { url } = await parentDeployment.genUpdatePackage({ + // createdBy: childIdentity._permalink + // }) + t.equal(copyFiles.callCount, 2) const [ @@ -372,3 +409,7 @@ test('deployment by referral', loudAsync(async (t) => { sandbox.restore() t.end() })) + +const getLastCallArgs = (stub: sinon.SinonStub) => { + return stub.getCall(stub.callCount - 1).args[0] +}