diff --git a/app/scripts/modules/core/src/region/RegionSelectField.tsx b/app/scripts/modules/core/src/region/RegionSelectField.tsx index 208a6ca301c..394debdf62e 100644 --- a/app/scripts/modules/core/src/region/RegionSelectField.tsx +++ b/app/scripts/modules/core/src/region/RegionSelectField.tsx @@ -26,6 +26,7 @@ export class RegionSelectField extends React.Component public render() { const { labelColumns, fieldColumns, account, regions, readOnly, component, field } = this.props; + return (
Region
diff --git a/app/scripts/modules/ecs/src/ecs.help.ts b/app/scripts/modules/ecs/src/ecs.help.ts index 2267f564797..67b788c6aed 100644 --- a/app/scripts/modules/ecs/src/ecs.help.ts +++ b/app/scripts/modules/ecs/src/ecs.help.ts @@ -24,6 +24,7 @@ const helpContents: { [key: string]: string } = { 'ecs.loadbalancing.targetPort': '

The port on which your application is listening for incoming traffic

', 'ecs.iamrole': '

The IAM role that your container (task, in AWS wording) will inherit.

Define a role only if your application needs to access AWS APIs

', + 'ecs.dockerimage': 'Docker image for your container, such as nginx:latest', 'ecs.dockerimagecredentials': '

The AWS Secrets Manager secret that contains private registry credentials.

Define credentials only for private registries other than Amazon ECR.

', 'ecs.placementStrategy': diff --git a/app/scripts/modules/ecs/src/ecs.module.ts b/app/scripts/modules/ecs/src/ecs.module.ts index 3499fc2713f..be9f43e8bb2 100644 --- a/app/scripts/modules/ecs/src/ecs.module.ts +++ b/app/scripts/modules/ecs/src/ecs.module.ts @@ -36,7 +36,7 @@ angular ECS_SERVER_GROUP_TRANSFORMER, // require('./pipeline/stages/cloneServerGroup/ecsCloneServerGroupStage').name, // TODO(Bruno Carrier): We should enable this on Clouddriver before revealing this stage require('./serverGroup/configure/wizard/advancedSettings/advancedSettings.component').name, - require('./serverGroup/configure/wizard/verticalScaling/verticalScaling.component').name, + require('./serverGroup/configure/wizard/container/container.component').name, require('./serverGroup/configure/wizard/horizontalScaling/horizontalScaling.component').name, ECS_SERVER_GROUP_LOGGING, ECS_NETWORKING_SECTION, diff --git a/app/scripts/modules/ecs/src/serverGroup/configure/serverGroupCommandBuilder.service.js b/app/scripts/modules/ecs/src/serverGroup/configure/serverGroupCommandBuilder.service.js index 4a15f77e2c3..ec66257cb2f 100644 --- a/app/scripts/modules/ecs/src/serverGroup/configure/serverGroupCommandBuilder.service.js +++ b/app/scripts/modules/ecs/src/serverGroup/configure/serverGroupCommandBuilder.service.js @@ -19,6 +19,81 @@ module.exports = angular function($q, instanceTypeService, ecsServerGroupConfigurationService) { const CLOUD_PROVIDER = 'ecs'; + function reconcileUpstreamImages(image, upstreamImages) { + if (image.fromContext) { + let matchingImage = upstreamImages.find(otherImage => image.stageId === otherImage.stageId); + + if (matchingImage) { + image.cluster = matchingImage.cluster; + image.pattern = matchingImage.pattern; + image.repository = matchingImage.repository; + return image; + } else { + return null; + } + } else if (image.fromTrigger) { + let matchingImage = upstreamImages.find(otherImage => { + return ( + image.registry === otherImage.registry && + image.repository === otherImage.repository && + image.tag === otherImage.tag + ); + }); + + if (matchingImage) { + return image; + } else { + return null; + } + } else { + return image; + } + } + + function findUpstreamImages(current, all, visited = {}) { + // This actually indicates a loop in the stage dependencies. + if (visited[current.refId]) { + return []; + } else { + visited[current.refId] = true; + } + let result = []; + if (current.type === 'findImageFromTags') { + result.push({ + fromContext: true, + imageLabelOrSha: current.imageLabelOrSha, + stageId: current.refId, + }); + } + current.requisiteStageRefIds.forEach(function(id) { + let next = all.find(stage => stage.refId === id); + if (next) { + result = result.concat(findUpstreamImages(next, all, visited)); + } + }); + + return result; + } + + function findTriggerImages(triggers) { + let result = triggers + .filter(trigger => { + return trigger.type === 'docker'; + }) + .map(trigger => { + return { + fromTrigger: true, + repository: trigger.repository, + account: trigger.account, + organization: trigger.organization, + registry: trigger.registry, + tag: trigger.tag, + }; + }); + + return result; + } + function buildNewServerGroupCommand(application, defaults) { defaults = defaults || {}; var credentialsLoader = AccountService.getCredentialsKeyedByAccount('ecs'); @@ -92,7 +167,7 @@ module.exports = angular }); } - function buildServerGroupCommandFromPipeline(application, originalCluster) { + function buildServerGroupCommandFromPipeline(application, originalCluster, current, pipeline) { var pipelineCluster = _.cloneDeep(originalCluster); var region = Object.keys(pipelineCluster.availabilityZones)[0]; // var instanceTypeCategoryLoader = instanceTypeService.getCategoryForInstanceType('ecs', pipelineCluster.instanceType); @@ -104,6 +179,13 @@ module.exports = angular var zones = pipelineCluster.availabilityZones[region]; var usePreferredZones = zones.join(',') === command.availabilityZones.join(','); + let contextImages = findUpstreamImages(current, pipeline.stages) || []; + contextImages = contextImages.concat(findTriggerImages(pipeline.triggers)); + + if (command.docker && command.docker.image) { + command.docker.image = reconcileUpstreamImages(command.docker.image, contextImages); + } + var viewState = { instanceProfile: asyncData.instanceProfile, disableImageSelection: true, @@ -116,6 +198,7 @@ module.exports = angular templatingEnabled: true, existingPipelineCluster: true, dirty: {}, + contextImages: contextImages, }; var viewOverrides = { @@ -132,9 +215,13 @@ module.exports = angular } // Only used to prepare view requiring template selecting - function buildNewServerGroupCommandForPipeline() { + function buildNewServerGroupCommandForPipeline(current, pipeline) { + let contextImages = findUpstreamImages(current, pipeline.stages) || []; + contextImages = contextImages.concat(findTriggerImages(pipeline.triggers)); + return $q.when({ viewState: { + contextImages: contextImages, requiresTemplateSelection: true, }, }); diff --git a/app/scripts/modules/ecs/src/serverGroup/configure/serverGroupConfiguration.service.ts b/app/scripts/modules/ecs/src/serverGroup/configure/serverGroupConfiguration.service.ts index f62c5f57301..01576e246dd 100644 --- a/app/scripts/modules/ecs/src/serverGroup/configure/serverGroupConfiguration.service.ts +++ b/app/scripts/modules/ecs/src/serverGroup/configure/serverGroupConfiguration.service.ts @@ -12,9 +12,11 @@ import { IServerGroupCommandBackingDataFiltered, IServerGroupCommandDirty, IServerGroupCommandResult, + IServerGroupCommandViewState, ISubnet, LOAD_BALANCER_READ_SERVICE, LoadBalancerReader, + NameUtils, SERVER_GROUP_COMMAND_REGISTRY_PROVIDER, ServerGroupCommandRegistry, SubnetReader, @@ -24,6 +26,7 @@ import { } from '@spinnaker/core'; import { IAmazonLoadBalancer } from '@spinnaker/amazon'; +import { DockerImageReader, IDockerImage } from '@spinnaker/docker'; import { IamRoleReader } from '../../iamRoles/iamRole.read.service'; import { EscClusterReader } from '../../ecsCluster/ecsCluster.read.service'; import { MetricAlarmReader } from '../../metricAlarm/metricAlarm.read.service'; @@ -43,6 +46,19 @@ export interface IEcsServerGroupCommandResult extends IServerGroupCommandResult dirty: IEcsServerGroupCommandDirty; } +export interface IEcsDockerImage extends IDockerImage { + imageId: string; + message: string; + fromTrigger: boolean; + fromContext: boolean; + stageId: string; + imageLabelOrSha: string; +} + +export interface IEcsServerGroupCommandViewState extends IServerGroupCommandViewState { + contextImages: IEcsDockerImage[]; +} + export interface IEcsServerGroupCommandBackingDataFiltered extends IServerGroupCommandBackingDataFiltered { targetGroups: string[]; iamRoles: string[]; @@ -51,6 +67,7 @@ export interface IEcsServerGroupCommandBackingDataFiltered extends IServerGroupC subnetTypes: string[]; securityGroupNames: string[]; secrets: string[]; + images: IEcsDockerImage[]; } export interface IEcsServerGroupCommandBackingData extends IServerGroupCommandBackingData { @@ -63,6 +80,7 @@ export interface IEcsServerGroupCommandBackingData extends IServerGroupCommandBa // subnetTypes: string; // securityGroups: string[] secrets: ISecretDescriptor[]; + images: IEcsDockerImage[]; } export interface IEcsServerGroupCommand extends IServerGroupCommand { @@ -71,11 +89,15 @@ export interface IEcsServerGroupCommand extends IServerGroupCommand { targetGroup: string; placementStrategyName: string; placementStrategySequence: IPlacementStrategy[]; + imageDescription: IEcsDockerImage; + viewState: IEcsServerGroupCommandViewState; subnetTypeChanged: (command: IEcsServerGroupCommand) => IServerGroupCommandResult; placementStrategyNameChanged: (command: IEcsServerGroupCommand) => IServerGroupCommandResult; // subnetTypeChanged: (command: IEcsServerGroupCommand) => IServerGroupCommandResult; regionIsDeprecated: (command: IEcsServerGroupCommand) => boolean; + + clusterChanged: (command: IServerGroupCommand) => void; } export class EcsServerGroupConfigurationService { @@ -115,7 +137,7 @@ export class EcsServerGroupConfigurationService { } // TODO (Bruno Carrier): Why do we need to inject an Application into this constructor so that the app works? This is strange, and needs investigating - public configureCommand(cmd: IEcsServerGroupCommand): IPromise { + public configureCommand(cmd: IEcsServerGroupCommand, imageQuery = ''): IPromise { this.applyOverrides('beforeConfiguration', cmd); cmd.toggleSuspendedProcess = (command: IEcsServerGroupCommand, process: string): void => { command.suspendedProcesses = command.suspendedProcesses || []; @@ -144,6 +166,29 @@ export class EcsServerGroupConfigurationService { ); }; + const imageQueries = cmd.imageDescription ? [this.grabImageAndTag(cmd.imageDescription.imageId)] : []; + + if (imageQuery) { + imageQueries.push(imageQuery); + } + + let imagesPromise; + if (imageQueries.length) { + imagesPromise = this.$q + .all( + imageQueries.map(q => + DockerImageReader.findImages({ + provider: 'dockerRegistry', + count: 50, + q: q, + }), + ), + ) + .then(promises => flatten(promises)); + } else { + imagesPromise = this.$q.when([]); + } + return this.$q .all({ credentialsKeyedByAccount: AccountService.getCredentialsKeyedByAccount('ecs'), @@ -155,10 +200,14 @@ export class EcsServerGroupConfigurationService { securityGroups: this.securityGroupReader.getAllSecurityGroups(), launchTypes: this.$q.when(clone(this.launchTypes)), secrets: this.secretReader.listSecrets(), + images: imagesPromise, }) .then((backingData: Partial) => { backingData.accounts = keys(backingData.credentialsKeyedByAccount); backingData.filtered = {} as IEcsServerGroupCommandBackingDataFiltered; + if (cmd.viewState.contextImages) { + backingData.images = backingData.images.concat(cmd.viewState.contextImages); + } cmd.backingData = backingData as IEcsServerGroupCommandBackingData; this.configureVpcId(cmd); this.configureAvailableIamRoles(cmd); @@ -166,6 +215,8 @@ export class EcsServerGroupConfigurationService { this.configureAvailableSecurityGroups(cmd); this.configureAvailableEcsClusters(cmd); this.configureAvailableSecrets(cmd); + this.configureAvailableImages(cmd); + this.configureAvailableRegions(cmd); this.applyOverrides('afterConfiguration', cmd); this.attachEventHandlers(cmd); }); @@ -179,6 +230,44 @@ export class EcsServerGroupConfigurationService { }); } + public grabImageAndTag(imageId: string): string { + return imageId.split('/').pop(); + } + + public buildImageId(image: IEcsDockerImage): string { + if (image.fromContext) { + return `${image.imageLabelOrSha}`; + } else if (image.fromTrigger && !image.tag) { + return `${image.registry}/${image.repository} (Tag resolved at runtime)`; + } else { + return `${image.registry}/${image.repository}:${image.tag}`; + } + } + + public mapImage(image: IEcsDockerImage): IEcsDockerImage { + if (image.message !== undefined) { + return image; + } + + return { + repository: image.repository, + tag: image.tag, + imageId: this.buildImageId(image), + registry: image.registry, + fromContext: image.fromContext, + fromTrigger: image.fromTrigger, + account: image.account, + imageLabelOrSha: image.imageLabelOrSha, + stageId: image.stageId, + message: image.message, + }; + } + + public configureAvailableImages(command: IEcsServerGroupCommand): void { + // No filtering required, but need to decorate with the displayable image ID + command.backingData.filtered.images = command.backingData.images.map(image => this.mapImage(image)); + } + public configureAvailabilityZones(command: IEcsServerGroupCommand): void { command.backingData.filtered.availabilityZones = find( command.backingData.credentialsKeyedByAccount[command.credentials].regions, @@ -249,6 +338,12 @@ export class EcsServerGroupConfigurationService { .value(); } + public configureAvailableRegions(command: IEcsServerGroupCommand): void { + const regionsForAccount: IAccountDetails = + command.backingData.credentialsKeyedByAccount[command.credentials] || ({ regions: [] } as IAccountDetails); + command.backingData.filtered.regions = regionsForAccount.regions; + } + public configureAvailableIamRoles(command: IEcsServerGroupCommand): void { command.backingData.filtered.iamRoles = chain(command.backingData.iamRoles) .filter({ accountName: command.credentials }) @@ -399,6 +494,10 @@ export class EcsServerGroupConfigurationService { return result; }; + cmd.clusterChanged = (command: IEcsServerGroupCommand): void => { + command.moniker = NameUtils.getMoniker(command.application, command.stack, command.freeFormDetails); + }; + cmd.credentialsChanged = (command: IEcsServerGroupCommand): IServerGroupCommandResult => { const result: IEcsServerGroupCommandResult = { dirty: {} }; const backingData = command.backingData; @@ -408,10 +507,8 @@ export class EcsServerGroupConfigurationService { this.configureAvailableSubnetTypes(command); this.configureAvailableSecurityGroups(command); this.configureAvailableSecrets(command); + this.configureAvailableRegions(command); - const regionsForAccount: IAccountDetails = - backingData.credentialsKeyedByAccount[command.credentials] || ({ regions: [] } as IAccountDetails); - backingData.filtered.regions = regionsForAccount.regions; if (!some(backingData.filtered.regions, { name: command.region })) { command.region = null; result.dirty.region = true; diff --git a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/CloneServerGroup.ecs.controller.js b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/CloneServerGroup.ecs.controller.js index 72ebcaa1416..018a82640e5 100644 --- a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/CloneServerGroup.ecs.controller.js +++ b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/CloneServerGroup.ecs.controller.js @@ -66,9 +66,9 @@ module.exports = angular 'ecs.serverGroup.basicSettings', require('./location/basicSettings.html'), ), - verticalScaling: overrideRegistry.getTemplate( - 'ecs.serverGroup.verticalScaling', - require('./verticalScaling/verticalScaling.html'), + container: overrideRegistry.getTemplate( + 'ecs.serverGroup.container', + require('./container/container.html'), ), horizontalScaling: overrideRegistry.getTemplate( 'ecs.serverGroup.horizontalScaling', diff --git a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/container/container.component.html b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/container/container.component.html new file mode 100644 index 00000000000..13e2c531e7b --- /dev/null +++ b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/container/container.component.html @@ -0,0 +1,46 @@ +
+
+
Container Image
+
+ + {{ $select.selected.imageId }} + + + + +
+
+ +
+
+ Reserved Compute Units +
+
+ +
+
+ +
+
+ Reserved Memory +
+
+ +
+
+
diff --git a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/container/container.component.js b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/container/container.component.js new file mode 100644 index 00000000000..560fbd14d74 --- /dev/null +++ b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/container/container.component.js @@ -0,0 +1,42 @@ +'use strict'; + +const angular = require('angular'); +import { Observable, Subject } from 'rxjs'; + +module.exports = angular + .module('spinnaker.ecs.serverGroup.configure.wizard.container.component', []) + .component('ecsServerGroupContainer', { + bindings: { + command: '=', + application: '=', + }, + templateUrl: require('./container.component.html'), + }) + .controller('ecsContainerImageController', function($scope, ecsServerGroupConfigurationService) { + this.groupByRegistry = function(image) { + if (image) { + if (image.fromContext) { + return 'Find Image Result(s)'; + } else if (image.fromTrigger) { + return 'Images from Trigger(s)'; + } else { + return image.registry; + } + } + }; + + function searchImages(cmd, q) { + return Observable.fromPromise(ecsServerGroupConfigurationService.configureCommand(cmd, q)); + } + + var imageSearchResultsStream = new Subject(); + + imageSearchResultsStream + .debounceTime(250) + .switchMap(searchImages) + .subscribe(); + + this.searchImages = function(q) { + imageSearchResultsStream.next(q); + }; + }); diff --git a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/container/container.html b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/container/container.html new file mode 100644 index 00000000000..b89b9ca579e --- /dev/null +++ b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/container/container.html @@ -0,0 +1,7 @@ +
+
+
+ +
+
+
diff --git a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/serverGroupWizard.html b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/serverGroupWizard.html index 53937f11c52..b98e89ca700 100644 --- a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/serverGroupWizard.html +++ b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/serverGroupWizard.html @@ -13,8 +13,8 @@

- - + + diff --git a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/verticalScaling/verticalScaling.component.html b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/verticalScaling/verticalScaling.component.html deleted file mode 100644 index 44f4df8d59b..00000000000 --- a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/verticalScaling/verticalScaling.component.html +++ /dev/null @@ -1,29 +0,0 @@ -
-
-
- Reserved Compute Units -
-
- -
-
- -
-
- Reserved Memory -
-
- -
-
-
diff --git a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/verticalScaling/verticalScaling.component.js b/app/scripts/modules/ecs/src/serverGroup/configure/wizard/verticalScaling/verticalScaling.component.js deleted file mode 100644 index 8bbebe2cb26..00000000000 --- a/app/scripts/modules/ecs/src/serverGroup/configure/wizard/verticalScaling/verticalScaling.component.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict'; - -const angular = require('angular'); - -module.exports = angular - .module('spinnaker.ecs.serverGroup.configure.wizard.verticalScaling.component', []) - .component('ecsServerGroupVerticalScaling', { - bindings: { - command: '=', - application: '=', - }, - templateUrl: require('./verticalScaling.component.html'), - }); diff --git a/app/scripts/modules/ecs/tsconfig.json b/app/scripts/modules/ecs/tsconfig.json index f53397f2ede..5857be404e4 100644 --- a/app/scripts/modules/ecs/tsconfig.json +++ b/app/scripts/modules/ecs/tsconfig.json @@ -32,6 +32,7 @@ "@spinnaker/core": ["../../core/lib"], "core/*": ["../../core/lib/*"], "@spinnaker/amazon": ["../../amazon/lib"], + "@spinnaker/docker": ["../../docker/lib"], "ecs/*": ["*"], "ecs": ["."] } diff --git a/app/scripts/modules/ecs/webpack.config.js b/app/scripts/modules/ecs/webpack.config.js index 2eaf75f08e3..7885610ee48 100644 --- a/app/scripts/modules/ecs/webpack.config.js +++ b/app/scripts/modules/ecs/webpack.config.js @@ -125,6 +125,7 @@ module.exports = { externals: [ '@spinnaker/core', '@spinnaker/amazon', + '@spinnaker/docker', 'exports-loader?"n3-line-chart"!n3-charts/build/LineChart.js', nodeExternals({ modulesDir: '../../../../node_modules' }), ],