diff --git a/halconfig/settings.js b/halconfig/settings.js index 039ea00f95c..3e3d6aaf303 100644 --- a/halconfig/settings.js +++ b/halconfig/settings.js @@ -63,6 +63,11 @@ var cloudfoundry = { account: '{%cloudfoundry.default.account%}', }, }; +var cloudrun = { + defaults: { + account: '{%cloudrun.default.account%}', + }, +}; var dcos = { defaults: { account: '{%dcos.default.account%}', @@ -145,6 +150,7 @@ window.spinnakerSettings = { aws: aws, azure: azure, cloudfoundry: cloudfoundry, + cloudrun: cloudrun, dcos: dcos, ecs: ecs, gce: gce, diff --git a/karma-shim.js b/karma-shim.js index 6aa2110839b..c0172403488 100644 --- a/karma-shim.js +++ b/karma-shim.js @@ -34,6 +34,9 @@ testContext.keys().forEach(testContext); testContext = require.context('./packages/cloudfoundry/src', true, /\.spec\.(js|ts|tsx)$/); testContext.keys().forEach(testContext); +testContext = require.context('./packages/cloudrun/src', true, /\.spec\.(js|ts|tsx)$/); +testContext.keys().forEach(testContext); + testContext = require.context('./packages/core/src', true, /\.spec\.(js|ts|tsx)$/); testContext.keys().forEach(testContext); diff --git a/karma.conf.js b/karma.conf.js index c5252fd14bd..1e00dae0485 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -32,6 +32,8 @@ const webpackConfig = { '@spinnaker/azure': path.resolve(`${MODULES_ROOT}/azure/src`), cloudfoundry: path.resolve(`${MODULES_ROOT}/cloudfoundry/src`), '@spinnaker/cloudfoundry': path.resolve(`${MODULES_ROOT}/cloudfoundry/src`), + cloudrun: path.resolve(`${MODULES_ROOT}/cloudrun/src`), + '@spinnaker/cloudrun': path.resolve(`${MODULES_ROOT}/cloudrun/src`), core: path.resolve(`${MODULES_ROOT}/core/src`), '@spinnaker/core': path.resolve(`${MODULES_ROOT}/core/src`), dcos: path.resolve(`${MODULES_ROOT}/dcos/src`), diff --git a/package.json b/package.json index 652749cef8b..189c3f8b7bf 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "packages/appengine", "packages/azure", "packages/cloudfoundry", + "packages/cloudrun", "packages/core", "packages/dcos", "packages/docker", diff --git a/packages/app/package.json b/packages/app/package.json index 0ce56b9134b..63b502f7f63 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,6 +17,7 @@ "@spinnaker/amazon": "^0.13.5", "@spinnaker/appengine": "^0.1.3", "@spinnaker/cloudfoundry": "^0.1.3", + "@spinnaker/cloudrun": "^0.0.1", "@spinnaker/core": "^0.23.0", "@spinnaker/docker": "^0.0.137", "@spinnaker/ecs": "^0.0.356", diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index 86c6d8fa28d..788fa85bb2e 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -15,6 +15,7 @@ import { ORACLE_MODULE } from '@spinnaker/oracle'; import { KAYENTA_MODULE } from '@spinnaker/kayenta'; import { TITUS_MODULE } from '@spinnaker/titus'; import { ECS_MODULE } from '@spinnaker/ecs'; +import { CLOUDRUN_MODULE } from '@spinnaker/cloudrun'; import '@spinnaker/cloudfoundry'; module('netflix.spinnaker', [ @@ -30,4 +31,5 @@ module('netflix.spinnaker', [ KUBERNETES_MODULE, KAYENTA_MODULE, TITUS_MODULE, + CLOUDRUN_MODULE, ]); diff --git a/packages/app/src/settings.js b/packages/app/src/settings.js index 6683223489c..555c7a55434 100644 --- a/packages/app/src/settings.js +++ b/packages/app/src/settings.js @@ -91,6 +91,7 @@ window.spinnakerSettings = { 'aws', 'azure', 'cloudfoundry', + 'cloudrun', 'dcos', 'ecs', 'gce', @@ -211,6 +212,11 @@ window.spinnakerSettings = { account: 'my-cloudfoundry-account', }, }, + cloudrun: { + defaults: { + account: 'my-cloudrun-account', + }, + }, dcos: { defaults: { account: 'my-dcos-account', diff --git a/packages/cloudrun/.npmignore b/packages/cloudrun/.npmignore new file mode 100644 index 00000000000..7879f9c4b4a --- /dev/null +++ b/packages/cloudrun/.npmignore @@ -0,0 +1,4 @@ +yalc.* +.* +tsconfig.json +webpack.config.js diff --git a/packages/cloudrun/package.json b/packages/cloudrun/package.json new file mode 100644 index 00000000000..ca1d181e31e --- /dev/null +++ b/packages/cloudrun/package.json @@ -0,0 +1,53 @@ +{ + "name": "@spinnaker/cloudrun", + "license": "Apache-2.0", + "version": "0.0.1", + "module": "dist/index.js", + "typings": "dist/index.d.ts", + "scripts": { + "clean": "shx rm -rf dist", + "prepublishOnly": "npm run build", + "build": "npm run clean && spinnaker-scripts build", + "dev": "spinnaker-scripts start", + "dev:push": "spinnaker-scripts start --push", + "lib": "npm run build" + }, + "dependencies": { + "@spinnaker/core": "^0.23.0", + "@uirouter/angularjs": "1.0.26", + "@uirouter/react": "1.0.7", + "angular": "1.6.10", + "angular-ui-bootstrap": "2.5.0", + "brace": "0.11.1", + "dompurify": "^2.3.10", + "enzyme": "3.11.0", + "formik": "1.5.1", + "js-yaml": "3.13.1", + "lodash": "4.17.21", + "luxon": "1.23.0", + "ngimport": "0.6.1", + "react": "16.14.0", + "react-ace": "6.4.0", + "react-bootstrap": "0.32.1", + "react-ga": "2.4.1", + "react-select": "1.2.1", + "react2angular": "3.2.1", + "rxjs": "6.6.7" + }, + "devDependencies": { + "@spinnaker/eslint-plugin": "^3.0.1", + "@spinnaker/scripts": "^0.3.0", + "@types/angular": "1.6.26", + "@types/angular-ui-bootstrap": "0.13.41", + "@types/dompurify": "^2.3.3", + "@types/enzyme": "3.10.3", + "@types/js-yaml": "3.5.30", + "@types/lodash": "4.14.64", + "@types/luxon": "1.11.1", + "@types/react": "16.14.10", + "@types/react-bootstrap": "0.32.5", + "@types/react-select": "1.3.4", + "shx": "0.3.3", + "typescript": "4.3.5" + } +} diff --git a/packages/cloudrun/src/cloudrun.module.ts b/packages/cloudrun/src/cloudrun.module.ts new file mode 100644 index 00000000000..4443a4ed27a --- /dev/null +++ b/packages/cloudrun/src/cloudrun.module.ts @@ -0,0 +1,72 @@ +import { module } from 'angular'; + +import { CloudProviderRegistry, DeploymentStrategyRegistry } from '@spinnaker/core'; + +import { CLOUDRUN_COMPONENT_URL_DETAILS } from './common/componentUrlDetails.component'; +import { CLOUDRUN_LOAD_BALANCER_CREATE_MESSAGE } from './common/loadBalancerMessage.component'; +import './help/cloudrun.help'; +import { CLOUDRUN_INSTANCE_DETAILS_CTRL } from './instance/details/details.controller'; +import { CLOUDRUN_ALLOCATION_CONFIGURATION_ROW } from './loadBalancer/configure/wizard/allocationConfigurationRow.component'; +import { CLOUDRUN_LOAD_BALANCER_BASIC_SETTINGS } from './loadBalancer/configure/wizard/basicSettings.component'; +import { CLOUDRUN_STAGE_ALLOCATION_CONFIGURATION_ROW } from './loadBalancer/configure/wizard/stageAllocationConfigurationRow.component'; +import { CLOUDRUN_LOAD_BALANCER_WIZARD_CTRL } from './loadBalancer/configure/wizard/wizard.controller'; +import { CLOUDRUN_LOAD_BALANCER_DETAILS_CTRL } from './loadBalancer/details/details.controller'; +import { CLOUDRUN_LOAD_BALANCER_TRANSFORMER } from './loadBalancer/loadBalancerTransformer'; +import logo from './logo/cloudrun.logo.png'; +import { CLOUDRUN_PIPELINE_MODULE } from './pipeline/pipeline.module'; +import { CLOUDRUN_SERVER_GROUP_COMMAND_BUILDER } from './serverGroup/configure/serverGroupCommandBuilder.service'; +import { ServerGroupWizard } from './serverGroup/configure/wizard/serverGroupWizard'; +import { CLOUDRUN_SERVER_GROUP_DETAILS_CTRL } from './serverGroup/details/details.controller'; +import { CLOUDRUN_SERVER_GROUP_TRANSFORMER } from './serverGroup/serverGroupTransformer.service'; + +import './logo/cloudrun.logo.less'; + +export const CLOUDRUN_MODULE = 'spinnaker.cloudrun'; + +const requires = [ + CLOUDRUN_COMPONENT_URL_DETAILS, + CLOUDRUN_SERVER_GROUP_COMMAND_BUILDER, + CLOUDRUN_SERVER_GROUP_DETAILS_CTRL, + CLOUDRUN_SERVER_GROUP_TRANSFORMER, + CLOUDRUN_LOAD_BALANCER_TRANSFORMER, + CLOUDRUN_LOAD_BALANCER_DETAILS_CTRL, + CLOUDRUN_LOAD_BALANCER_WIZARD_CTRL, + CLOUDRUN_LOAD_BALANCER_CREATE_MESSAGE, + CLOUDRUN_ALLOCATION_CONFIGURATION_ROW, + CLOUDRUN_LOAD_BALANCER_BASIC_SETTINGS, + CLOUDRUN_STAGE_ALLOCATION_CONFIGURATION_ROW, + CLOUDRUN_PIPELINE_MODULE, + CLOUDRUN_INSTANCE_DETAILS_CTRL, +]; + +module(CLOUDRUN_MODULE, requires).config(() => { + CloudProviderRegistry.registerProvider('cloudrun', { + name: 'cloudrun', + logo: { + path: logo, + }, + + instance: { + detailsTemplateUrl: require('./instance/details/details.html'), + detailsController: 'cloudrunInstanceDetailsCtrl', + }, + serverGroup: { + CloneServerGroupModal: ServerGroupWizard, + commandBuilder: 'cloudrunV2ServerGroupCommandBuilder', + detailsController: 'cloudrunV2ServerGroupDetailsCtrl', + detailsTemplateUrl: require('./serverGroup/details/details.html'), + transformer: 'cloudrunV2ServerGroupTransformer', + skipUpstreamStageCheck: true, + }, + + loadBalancer: { + transformer: 'cloudrunLoadBalancerTransformer', + createLoadBalancerTemplateUrl: require('./loadBalancer/configure/wizard/wizard.html'), + createLoadBalancerController: 'cloudrunLoadBalancerWizardCtrl', + detailsTemplateUrl: require('./loadBalancer/details/details.html'), + detailsController: 'cloudrunLoadBalancerDetailsCtrl', + }, + }); +}); + +DeploymentStrategyRegistry.registerProvider('cloudrun', ['custom', 'redblack', 'rollingpush', 'rollingredblack']); diff --git a/packages/cloudrun/src/cloudrun.settings.ts b/packages/cloudrun/src/cloudrun.settings.ts new file mode 100644 index 00000000000..f3b8cc0b080 --- /dev/null +++ b/packages/cloudrun/src/cloudrun.settings.ts @@ -0,0 +1,14 @@ +import type { IProviderSettings } from '@spinnaker/core'; +import { SETTINGS } from '@spinnaker/core'; + +export interface ICloudrunProviderSettings extends IProviderSettings { + defaults: { + account?: string; + }; +} + +export const CloudrunProviderSettings: ICloudrunProviderSettings = (SETTINGS.providers + .cloudrun as ICloudrunProviderSettings) || { defaults: {} }; +if (CloudrunProviderSettings) { + CloudrunProviderSettings.resetToOriginal = SETTINGS.resetProvider('cloudrun'); +} diff --git a/packages/cloudrun/src/common/cloudrunHealth.ts b/packages/cloudrun/src/common/cloudrunHealth.ts new file mode 100644 index 00000000000..5dfc89243af --- /dev/null +++ b/packages/cloudrun/src/common/cloudrunHealth.ts @@ -0,0 +1,3 @@ +export class CloudrunHealth { + public static PLATFORM = 'Cloud Run Service'; +} diff --git a/packages/cloudrun/src/common/componentUrlDetails.component.ts b/packages/cloudrun/src/common/componentUrlDetails.component.ts new file mode 100644 index 00000000000..3fdf0e43e58 --- /dev/null +++ b/packages/cloudrun/src/common/componentUrlDetails.component.ts @@ -0,0 +1,22 @@ +import type { IComponentOptions } from 'angular'; +import { module } from 'angular'; + +const cloudrunComponentUrlDetailsComponent: IComponentOptions = { + bindings: { component: '<' }, + template: ` +
HTTPS
+
+ {{$ctrl.component.url}} + +
+ `, +}; + +export const CLOUDRUN_COMPONENT_URL_DETAILS = 'spinnaker.cloudrun.componentUrlDetails.component'; + +module(CLOUDRUN_COMPONENT_URL_DETAILS, []).component( + 'cloudrunComponentUrlDetails', + cloudrunComponentUrlDetailsComponent, +); diff --git a/packages/cloudrun/src/common/conditionalDescriptionListItem.component.ts b/packages/cloudrun/src/common/conditionalDescriptionListItem.component.ts new file mode 100644 index 00000000000..59f72a77066 --- /dev/null +++ b/packages/cloudrun/src/common/conditionalDescriptionListItem.component.ts @@ -0,0 +1,37 @@ +import type { IComponentOptions, IController, IFilterService } from 'angular'; +import { module } from 'angular'; + +class CloudrunConditionalDescriptionListItemCtrl implements IController { + public label: string; + public key: string; + public component: any; + + public static $inject = ['$filter']; + constructor(private $filter: IFilterService) {} + + public $onInit(): void { + if (!this.label) { + this.label = this.$filter('robotToHuman')(this.key); + } + } +} + +const cloudrunConditionalDescriptionListItem: IComponentOptions = { + bindings: { label: '@', key: '@', component: '<' }, + transclude: { + keyLabel: '?keyText', + valueLabel: '?valueLabel', + }, + template: ` +
{{$ctrl.label}}
+
{{$ctrl.component[$ctrl.key]}}
+ `, + controller: CloudrunConditionalDescriptionListItemCtrl, +}; + +export const CLOUDRUN_CONDITIONAL_DESCRIPTION_LIST_ITEM = 'spinnaker.cloudrun.conditionalDescriptionListItem'; + +module(CLOUDRUN_CONDITIONAL_DESCRIPTION_LIST_ITEM, []).component( + 'cloudrunConditionalDtDd', + cloudrunConditionalDescriptionListItem, +); diff --git a/packages/cloudrun/src/common/domain/ICloudrunInstance.ts b/packages/cloudrun/src/common/domain/ICloudrunInstance.ts new file mode 100644 index 00000000000..d2805b27fca --- /dev/null +++ b/packages/cloudrun/src/common/domain/ICloudrunInstance.ts @@ -0,0 +1,21 @@ +import type { IInstance } from '@spinnaker/core'; + +export interface ICloudrunInstance extends IInstance { + name: string; + id: string; + account?: string; + region?: string; + instanceStatus: 'DYNAMIC' | 'RESIDENT' | 'UNKNOWN'; + launchTime: number; + loadBalancers: string[]; + serverGroup: string; + vmDebugEnabled: boolean; + vmName: string; + vmStatus: string; + vmZoneName: string; + qps: number; + healthState: string; + cloudProvider: string; + errors: number; + averageLatency: number; +} diff --git a/packages/cloudrun/src/common/domain/ICloudrunLoadBalancer.ts b/packages/cloudrun/src/common/domain/ICloudrunLoadBalancer.ts new file mode 100644 index 00000000000..a8845c5aef4 --- /dev/null +++ b/packages/cloudrun/src/common/domain/ICloudrunLoadBalancer.ts @@ -0,0 +1,18 @@ +import type { ILoadBalancer } from '@spinnaker/core'; + +export interface ICloudrunLoadBalancer extends ILoadBalancer { + credentials?: string; + split?: ICloudrunTrafficSplit; + migrateTraffic: boolean; + dispatchRules?: ICloudrunDispatchRule[]; +} + +export interface ICloudrunTrafficSplit { + trafficTargets: [{ revisionName: string; percent: number }]; +} + +export interface ICloudrunDispatchRule { + domain: string; + path: string; + service: string; +} diff --git a/packages/cloudrun/src/common/domain/index.ts b/packages/cloudrun/src/common/domain/index.ts new file mode 100644 index 00000000000..0f4f8e82793 --- /dev/null +++ b/packages/cloudrun/src/common/domain/index.ts @@ -0,0 +1,2 @@ +export * from './ICloudrunLoadBalancer'; +export * from './ICloudrunInstance'; diff --git a/packages/cloudrun/src/common/loadBalancerMessage.component.html b/packages/cloudrun/src/common/loadBalancerMessage.component.html new file mode 100644 index 00000000000..b59d7f5555a --- /dev/null +++ b/packages/cloudrun/src/common/loadBalancerMessage.component.html @@ -0,0 +1,11 @@ +
+
+
+

+ Spinnaker cannot create a load balancer for Cloud Run. + A Spinnaker load balancer maps to an Cloud Run Service, which will be created automatically alongside a + Revision. Once created, the Service can be edited as a Load Balancer. +

+
+
+
diff --git a/packages/cloudrun/src/common/loadBalancerMessage.component.ts b/packages/cloudrun/src/common/loadBalancerMessage.component.ts new file mode 100644 index 00000000000..c0d14e6a5f1 --- /dev/null +++ b/packages/cloudrun/src/common/loadBalancerMessage.component.ts @@ -0,0 +1,13 @@ +import { module } from 'angular'; + +const cloudRunLoadBalancerMessageComponent: ng.IComponentOptions = { + bindings: { showCreateMessage: '<', columnOffset: '@', columns: '@' }, + templateUrl: require('./loadBalancerMessage.component.html'), +}; + +export const CLOUDRUN_LOAD_BALANCER_CREATE_MESSAGE = 'spinnaker.cloudrun.loadBalancer.createMessage.component'; + +module(CLOUDRUN_LOAD_BALANCER_CREATE_MESSAGE, []).component( + 'cloudrunLoadBalancerMessage', + cloudRunLoadBalancerMessageComponent, +); diff --git a/packages/cloudrun/src/help/cloudrun.help.ts b/packages/cloudrun/src/help/cloudrun.help.ts new file mode 100644 index 00000000000..2f4be46b1be --- /dev/null +++ b/packages/cloudrun/src/help/cloudrun.help.ts @@ -0,0 +1,67 @@ +import { HelpContentsRegistry } from '@spinnaker/core'; + +const helpContents = [ + { + key: 'cloudrun.serverGroup.stack', + value: + '(Optional) Stack is one of the core naming components of a cluster, used to create vertical stacks of dependent services for integration testing.', + }, + + { + key: 'cloudrun.serverGroup.file', + value: `
+    apiVersion: serving.knative.dev/v1
+    kind: Service
+    metadata:
+        name: spinappcloud1
+        namespace: '135005621049'
+        labels:
+            cloud.googleapis.com/location: us-central1
+    annotations:
+        run.googleapis.com/client-name: cloud-console
+        serving.knative.dev/creator: kiran@opsmx.io
+        serving.knative.dev/lastModifier: kiran@opsmx.io
+        client.knative.dev/user-image: us-docker.pkg.dev/cloudrun/container/hello
+        run.googleapis.com/ingress-status: all
+    spec:
+        template:
+        metagoogleapis.com/ingress: all
+        run.data:
+        name: spinappcloud1
+    annotations:
+        run.googleapis.com/client-name: cloud-console
+        autoscaling.knative.dev/minScale: '1'
+        autoscaling.knative.dev/maxScale: '3'
+    spec:
+        containerConcurrency: 80
+        timeoutSeconds: 200
+        serviceAccountName:spinnaker-cloudrun-account@my-orbit-project-71824.iam.gserviceaccount.com
+        containers:
+           - image:us-docker.pkg.dev/cloudrun/container/hello
+        ports:
+           - name: http1
+        containerPort: 8080
+        resources:
+        limits:
+        cpu: 1000m
+        memory: 256Mi  
+
+ `, + }, + + { + key: 'cloudrun.serverGroup.detail', + value: + ' (Optional) Detail is a string of free-form alphanumeric characters and hyphens to describe any other variables.', + }, + { + key: 'cloudrun.serverGroup.configFiles', + value: `

The contents of a Cloud Run Service yaml

`, + }, + { + key: 'cloudrun.loadBalancer.allocations', + value: 'An allocation is the percent of traffic directed to a server group.', + }, +]; + +helpContents.forEach((entry) => HelpContentsRegistry.register(entry.key, entry.value)); diff --git a/packages/cloudrun/src/index.ts b/packages/cloudrun/src/index.ts new file mode 100644 index 00000000000..e0595fbf9f4 --- /dev/null +++ b/packages/cloudrun/src/index.ts @@ -0,0 +1,3 @@ +export * from './cloudrun.module'; +export * from './serverGroup'; +export * from './loadBalancer'; diff --git a/packages/cloudrun/src/instance/details/details.controller.ts b/packages/cloudrun/src/instance/details/details.controller.ts new file mode 100644 index 00000000000..d8fa5a8f555 --- /dev/null +++ b/packages/cloudrun/src/instance/details/details.controller.ts @@ -0,0 +1,84 @@ +import type { IController, IQService } from 'angular'; +import { module } from 'angular'; +import { flattenDeep } from 'lodash'; + +import type { Application, ILoadBalancer } from '@spinnaker/core'; +import { InstanceReader, RecentHistoryService } from '@spinnaker/core'; +import type { ICloudrunInstance } from '../../common/domain'; + +interface InstanceFromStateParams { + instanceId: string; +} + +interface InstanceManager { + account: string; + region: string; + category: string; // e.g., serverGroup, loadBalancer. + name: string; // Parent resource name, not instance name. + instances: ICloudrunInstance[]; +} + +class CloudrunInstanceDetailsController implements IController { + public state = { loading: true }; + public instance: ICloudrunInstance; + public instanceIdNotFound: string; + public upToolTip = "A Cloud Run instance is 'Up' if a load balancer is directing traffic to its server group."; + public outOfServiceToolTip = ` + A Cloud Run instance is 'Out Of Service' if no load balancers are directing traffic to its server group.`; + + public static $inject = ['$q', 'app', 'instance']; + + constructor(private $q: IQService, private app: Application, instance: InstanceFromStateParams) { + this.app + .ready() + .then(() => this.retrieveInstance(instance)) + .then((instanceDetails) => { + this.instance = instanceDetails; + this.state.loading = false; + }) + .catch(() => { + this.instanceIdNotFound = instance.instanceId; + this.state.loading = false; + }); + } + + private retrieveInstance(instance: InstanceFromStateParams): PromiseLike { + const instanceLocatorPredicate = (dataSource: InstanceManager) => { + return dataSource.instances.some((possibleMatch) => possibleMatch.id === instance.instanceId); + }; + + const dataSources: InstanceManager[] = flattenDeep([ + this.app.getDataSource('serverGroups').data, + this.app.getDataSource('loadBalancers').data, + this.app.getDataSource('loadBalancers').data.map((loadBalancer: ILoadBalancer) => loadBalancer.serverGroups), + ]); + + const instanceManager = dataSources.find(instanceLocatorPredicate); + + if (instanceManager) { + const recentHistoryExtraData: { [key: string]: string } = { + region: instanceManager.region, + account: instanceManager.account, + }; + if (instanceManager.category === 'serverGroup') { + recentHistoryExtraData.serverGroup = instanceManager.name; + } + RecentHistoryService.addExtraDataToLatest('instances', recentHistoryExtraData); + + return InstanceReader.getInstanceDetails( + instanceManager.account, + instanceManager.region, + instance.instanceId, + ).then((instanceDetails: ICloudrunInstance) => { + instanceDetails.account = instanceManager.account; + instanceDetails.region = instanceManager.region; + return instanceDetails; + }); + } else { + return this.$q.reject(); + } + } +} + +export const CLOUDRUN_INSTANCE_DETAILS_CTRL = 'spinnaker.cloudrun.instanceDetails.controller'; +module(CLOUDRUN_INSTANCE_DETAILS_CTRL, []).controller('cloudrunInstanceDetailsCtrl', CloudrunInstanceDetailsController); diff --git a/packages/cloudrun/src/instance/details/details.html b/packages/cloudrun/src/instance/details/details.html new file mode 100644 index 00000000000..cd8117a3658 --- /dev/null +++ b/packages/cloudrun/src/instance/details/details.html @@ -0,0 +1,68 @@ +
+
+ +
+
+ +
+
+
+
+ +
+
Launched
+
{{ctrl.instance.launchTime | timestamp}}
+
In
+
{{}}
+
Server Group
+
+ {{ctrl.instance.serverGroup}} +
+
Region
+
{{ctrl.instance.region}}
+ +
+
+ +
+
Load Balancer
+
+ + + {{ctrl.instance.loadBalancers[0]}} + +
+
+
+
+
+
+
+

Instance not found.

+
+
+
+
diff --git a/packages/cloudrun/src/interfaces/index.ts b/packages/cloudrun/src/interfaces/index.ts new file mode 100644 index 00000000000..14605457055 --- /dev/null +++ b/packages/cloudrun/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from './infrastructure.types'; diff --git a/packages/cloudrun/src/interfaces/infrastructure.types.ts b/packages/cloudrun/src/interfaces/infrastructure.types.ts new file mode 100644 index 00000000000..a1afe582c6a --- /dev/null +++ b/packages/cloudrun/src/interfaces/infrastructure.types.ts @@ -0,0 +1,23 @@ +import type { IInstance, ILoadBalancer, IMoniker, IServerGroup } from '@spinnaker/core'; + +export interface ICloudrunResource { + apiVersion: string; + createdTime?: number; + displayName: string; + kind: string; + namespace: string; +} + +export interface ICloudrunInstance extends IInstance, ICloudrunResource { + humanReadableName: string; + moniker: IMoniker; + publicDnsName?: string; +} + +export interface ICloudrunLoadBalancer extends ILoadBalancer, ICloudrunResource {} + +export interface ICloudrunServerGroup extends IServerGroup, ICloudrunResource { + disabled: boolean; +} + +//export interface ICloudrunServerGroupManager extends IServerGroupManager, ICloudrunResource {} diff --git a/packages/cloudrun/src/loadBalancer/configure/wizard/allocationConfigurationRow.component.ts b/packages/cloudrun/src/loadBalancer/configure/wizard/allocationConfigurationRow.component.ts new file mode 100644 index 00000000000..5189b4c47cc --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/configure/wizard/allocationConfigurationRow.component.ts @@ -0,0 +1,70 @@ +import type { IComponentOptions, IController } from 'angular'; +import { module } from 'angular'; +import { uniq } from 'lodash'; + +import type { ICloudrunAllocationDescription } from '../../loadBalancerTransformer'; + +class CloudrunAllocationConfigurationRowCtrl implements IController { + public allocationDescription: ICloudrunAllocationDescription; + public serverGroupOptions: string[]; + + public getServerGroupOptions(): string[] { + if (this.allocationDescription.revisionName) { + return uniq(this.serverGroupOptions.concat(this.allocationDescription.revisionName)); + } else { + return this.serverGroupOptions; + } + } +} + +const cloudrunAllocationConfigurationRowComponent: IComponentOptions = { + bindings: { + allocationDescription: '<', + removeAllocation: '&', + serverGroupOptions: '<', + onAllocationChange: '&', + }, + template: ` +
+
+
+ + + {{$select.selected}} + + +
+
+
+
+
+
+ + % +
+
+
+ + + +
+
+
+ `, + controller: CloudrunAllocationConfigurationRowCtrl, +}; + +export const CLOUDRUN_ALLOCATION_CONFIGURATION_ROW = 'spinnaker.cloudrun.allocationConfigurationRow.component'; + +module(CLOUDRUN_ALLOCATION_CONFIGURATION_ROW, []).component( + 'cloudrunAllocationConfigurationRow', + cloudrunAllocationConfigurationRowComponent, +); diff --git a/packages/cloudrun/src/loadBalancer/configure/wizard/basicSettings.component.html b/packages/cloudrun/src/loadBalancer/configure/wizard/basicSettings.component.html new file mode 100644 index 00000000000..97668ba57ef --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/configure/wizard/basicSettings.component.html @@ -0,0 +1,43 @@ + +
+
+
+ Allocations + +
+
+
+ + +
+
+ + +
+ +
+
+
+
+

Allocations must sum to 100%.

+
+
+
+
diff --git a/packages/cloudrun/src/loadBalancer/configure/wizard/basicSettings.component.ts b/packages/cloudrun/src/loadBalancer/configure/wizard/basicSettings.component.ts new file mode 100644 index 00000000000..afae927d82b --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/configure/wizard/basicSettings.component.ts @@ -0,0 +1,86 @@ +import type { IController } from 'angular'; +import { module } from 'angular'; +import { difference } from 'lodash'; + +import type { CloudrunLoadBalancerUpsertDescription } from '../../loadBalancerTransformer'; + +class CloudrunLoadBalancerSettingsController implements IController { + public loadBalancer: CloudrunLoadBalancerUpsertDescription; + public serverGroupOptions: string[]; + public forPipelineConfig: boolean; + + public $onInit(): void { + this.updateServerGroupOptions(); + } + + public addAllocation(): void { + const remainingServerGroups = this.serverGroupsWithoutAllocation(); + if (remainingServerGroups.length) { + this.loadBalancer.splitDescription.allocationDescriptions.push({ + revisionName: remainingServerGroups[0], + percent: 0, + }); + this.updateServerGroupOptions(); + } else if (this.forPipelineConfig) { + this.loadBalancer.splitDescription.allocationDescriptions.push({ + percent: 0, + revisionName: '', + }); + } + } + + public removeAllocation(index: number): void { + this.loadBalancer.splitDescription.allocationDescriptions.splice(index, 1); + this.updateServerGroupOptions(); + } + + public allocationIsInvalid(): boolean { + return ( + this.loadBalancer.splitDescription.allocationDescriptions.reduce( + (sum, allocationDescription) => sum + allocationDescription.percent, + 0, + ) !== 100 + ); + } + + public updateServerGroupOptions(): void { + this.serverGroupOptions = this.serverGroupsWithoutAllocation(); + } + + public showAddButton(): boolean { + if (this.forPipelineConfig) { + return true; + } else { + return this.serverGroupsWithoutAllocation().length > 0; + } + } + + public initializeAsTextInput(serverGroupName: string): boolean { + if (this.forPipelineConfig) { + return !this.loadBalancer.serverGroups.map((serverGroup) => serverGroup.name).includes(serverGroupName); + } else { + return false; + } + } + + private serverGroupsWithoutAllocation(): string[] { + const serverGroupsWithAllocation = this.loadBalancer.splitDescription.allocationDescriptions.map( + (description) => description.revisionName, + ); + const allServerGroups = this.loadBalancer.serverGroups.map((serverGroup) => serverGroup.name); + return difference(allServerGroups, serverGroupsWithAllocation); + } +} + +const cloudrunLoadBalancerSettingsComponent: ng.IComponentOptions = { + bindings: { loadBalancer: '=', forPipelineConfig: '<', application: '<' }, + controller: CloudrunLoadBalancerSettingsController, + templateUrl: require('./basicSettings.component.html'), +}; + +export const CLOUDRUN_LOAD_BALANCER_BASIC_SETTINGS = 'spinnaker.cloudrun.loadBalancerSettings.component'; + +module(CLOUDRUN_LOAD_BALANCER_BASIC_SETTINGS, []).component( + 'cloudrunLoadBalancerBasicSettings', + cloudrunLoadBalancerSettingsComponent, +); diff --git a/packages/cloudrun/src/loadBalancer/configure/wizard/stageAllocationConfigurationRow.component.html b/packages/cloudrun/src/loadBalancer/configure/wizard/stageAllocationConfigurationRow.component.html new file mode 100644 index 00000000000..46dea2edcd1 --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/configure/wizard/stageAllocationConfigurationRow.component.html @@ -0,0 +1,27 @@ +
+
+
+ + +
+
+
+ + % +
+
+
+ + + +
+
+
diff --git a/packages/cloudrun/src/loadBalancer/configure/wizard/stageAllocationConfigurationRow.component.ts b/packages/cloudrun/src/loadBalancer/configure/wizard/stageAllocationConfigurationRow.component.ts new file mode 100644 index 00000000000..ce62882f2c2 --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/configure/wizard/stageAllocationConfigurationRow.component.ts @@ -0,0 +1,79 @@ +import type { IComponentOptions, IController } from 'angular'; +import { module } from 'angular'; +import { uniq } from 'lodash'; + +import type { Application } from '@spinnaker/core'; +import { AppListExtractor, StageConstants } from '@spinnaker/core'; + +import type { ICloudrunAllocationDescription } from '../../loadBalancerTransformer'; + +class CloudrunStageAllocationLabelCtrl implements IController { + public inputViewValue: string; + private allocationDescription: ICloudrunAllocationDescription; + + public $doCheck(): void { + this.setInputViewValue(); + } + + private setInputViewValue(): void { + this.inputViewValue = this.allocationDescription.revisionName; + } +} + +const cloudrunStageAllocationLabel: IComponentOptions = { + bindings: { allocationDescription: '<' }, + controller: CloudrunStageAllocationLabelCtrl, + template: ``, +}; + +class CloudrunStageAllocationConfigurationRowCtrl implements IController { + public allocationDescription: ICloudrunAllocationDescription; + public serverGroupOptions: string[]; + public targets = StageConstants.TARGET_LIST; + public clusterList: string[]; + public onAllocationChange: Function; + private application: Application; + private region: string; + private account: string; + + public $onInit() { + const clusterFilter = AppListExtractor.clusterFilterForCredentialsAndRegion(this.account, this.region); + this.clusterList = AppListExtractor.getClusters([this.application], clusterFilter); + } + + public getServerGroupOptions(): string[] { + if (this.allocationDescription.revisionName) { + return uniq(this.serverGroupOptions.concat(this.allocationDescription.revisionName)); + } else { + return this.serverGroupOptions; + } + } + + public onLocatorTypeChange(): void { + // Prevents pipeline expressions (or non-existent server groups) from entering the dropdown. + if (!this.serverGroupOptions.includes(this.allocationDescription.revisionName)) { + delete this.allocationDescription.revisionName; + } + this.onAllocationChange(); + } +} + +const cloudrunStageAllocationConfigurationRow: IComponentOptions = { + bindings: { + application: '<', + region: '@', + account: '@', + allocationDescription: '<', + removeAllocation: '&', + serverGroupOptions: '<', + onAllocationChange: '&', + }, + controller: CloudrunStageAllocationConfigurationRowCtrl, + templateUrl: require('./stageAllocationConfigurationRow.component.html'), +}; + +export const CLOUDRUN_STAGE_ALLOCATION_CONFIGURATION_ROW = + 'spinnaker.cloudrun.stageAllocationConfigurationRow.component'; +module(CLOUDRUN_STAGE_ALLOCATION_CONFIGURATION_ROW, []) + .component('cloudrunStageAllocationConfigurationRow', cloudrunStageAllocationConfigurationRow) + .component('cloudrunStageAllocationLabel', cloudrunStageAllocationLabel); diff --git a/packages/cloudrun/src/loadBalancer/configure/wizard/wizard.controller.ts b/packages/cloudrun/src/loadBalancer/configure/wizard/wizard.controller.ts new file mode 100644 index 00000000000..38ed17f38b6 --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/configure/wizard/wizard.controller.ts @@ -0,0 +1,157 @@ +import type { StateService } from '@uirouter/angularjs'; +import type { IController } from 'angular'; +import { module } from 'angular'; +import type { IModalServiceInstance } from 'angular-ui-bootstrap'; +import { cloneDeep } from 'lodash'; + +import type { Application } from '@spinnaker/core'; +import { LoadBalancerWriter, TaskMonitor } from '@spinnaker/core'; + +import type { CloudrunLoadBalancerTransformer, ICloudrunTrafficSplitDescription } from '../../loadBalancerTransformer'; +import { CloudrunLoadBalancerUpsertDescription } from '../../loadBalancerTransformer'; + +import './wizard.less'; + +class CloudrunLoadBalancerWizardController implements IController { + public state = { loading: true }; + public loadBalancer: CloudrunLoadBalancerUpsertDescription; + public heading: string; + public submitButtonLabel: string; + public taskMonitor: TaskMonitor; + + public static $inject = [ + '$scope', + '$state', + '$uibModalInstance', + 'application', + 'loadBalancer', + 'isNew', + 'forPipelineConfig', + 'cloudrunLoadBalancerTransformer', + 'wizardSubFormValidation', + ]; + constructor( + public $scope: ng.IScope, + private $state: StateService, + private $uibModalInstance: IModalServiceInstance, + private application: Application, + loadBalancer: CloudrunLoadBalancerUpsertDescription, + public isNew: boolean, + private forPipelineConfig: boolean, + private cloudrunLoadBalancerTransformer: CloudrunLoadBalancerTransformer, + private wizardSubFormValidation: any, + ) { + this.submitButtonLabel = this.forPipelineConfig ? 'Done' : 'Update'; + + if (this.isNew) { + this.heading = 'Create New Load Balancer'; + } else { + this.heading = `Edit ${[ + loadBalancer.name, + loadBalancer.region, + loadBalancer.account || loadBalancer.credentials, + ].join(':')}`; + this.cloudrunLoadBalancerTransformer + .convertLoadBalancerForEditing(loadBalancer, application) + .then((convertedLoadBalancer) => { + this.loadBalancer = this.cloudrunLoadBalancerTransformer.convertLoadBalancerToUpsertDescription( + convertedLoadBalancer, + ); + if (loadBalancer.split && !this.loadBalancer.splitDescription) { + this.loadBalancer.splitDescription = CloudrunLoadBalancerUpsertDescription.convertTrafficSplitToTrafficSplitDescription( + loadBalancer.split, + ); + } else { + this.loadBalancer.splitDescription = loadBalancer.splitDescription; + } + this.loadBalancer.mapAllocationsToPercentages(); + this.setTaskMonitor(); + this.initializeFormValidation(); + this.state.loading = false; + }); + } + } + + public submit(): any { + const description = cloneDeep(this.loadBalancer); + description.mapAllocationsToPercentages(); + delete description.serverGroups; + + if (this.forPipelineConfig) { + return this.$uibModalInstance.close(description); + } else { + return this.taskMonitor.submit(() => { + return LoadBalancerWriter.upsertLoadBalancer(description, this.application, 'Update'); + }); + } + } + + public cancel(): void { + this.$uibModalInstance.dismiss(); + } + + public showSubmitButton(): boolean { + return this.wizardSubFormValidation.subFormsAreValid(); + } + + private setTaskMonitor(): void { + this.taskMonitor = new TaskMonitor({ + application: this.application, + title: 'Updating your load balancer', + modalInstance: this.$uibModalInstance, + onTaskComplete: () => this.onTaskComplete(), + }); + } + + private initializeFormValidation(): void { + this.wizardSubFormValidation.config({ form: 'form', scope: this.$scope }).register({ + page: 'basic-settings', + subForm: 'basicSettingsForm', + validators: [ + { + watchString: 'ctrl.loadBalancer.splitDescription', + validator: (splitDescription: ICloudrunTrafficSplitDescription): boolean => { + return ( + splitDescription.allocationDescriptions.reduce((sum, description) => sum + description.percent, 0) === 100 + ); + }, + watchDeep: true, + }, + ], + }); + } + + private onTaskComplete(): void { + this.application.getDataSource('loadBalancers').refresh(); + this.application.getDataSource('loadBalancers').onNextRefresh(this.$scope, () => this.onApplicationRefresh()); + } + + private onApplicationRefresh(): void { + // If the user has already closed the modal, do not navigate to the new details view + if ((this.$scope as any).$$destroyed) { + // $$destroyed is not in the ng.IScope interface + return; + } + + this.$uibModalInstance.dismiss(); + const newStateParams = { + name: this.loadBalancer.name, + accountId: this.loadBalancer.credentials, + region: this.loadBalancer.region, + provider: 'cloudrun', + }; + + if (!this.$state.includes('**.loadBalancerDetails')) { + this.$state.go('.loadBalancerDetails', newStateParams); + } else { + this.$state.go('^.loadBalancerDetails', newStateParams); + } + } +} + +export const CLOUDRUN_LOAD_BALANCER_WIZARD_CTRL = 'spinnaker.cloudrun.loadBalancer.wizard.controller'; + +module(CLOUDRUN_LOAD_BALANCER_WIZARD_CTRL, []).controller( + 'cloudrunLoadBalancerWizardCtrl', + CloudrunLoadBalancerWizardController, +); diff --git a/packages/cloudrun/src/loadBalancer/configure/wizard/wizard.html b/packages/cloudrun/src/loadBalancer/configure/wizard/wizard.html new file mode 100644 index 00000000000..970d993560d --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/configure/wizard/wizard.html @@ -0,0 +1,39 @@ +
+
+ +
+ +
+ + + +
+
+ + +
diff --git a/packages/cloudrun/src/loadBalancer/configure/wizard/wizard.less b/packages/cloudrun/src/loadBalancer/configure/wizard/wizard.less new file mode 100644 index 00000000000..66523c33345 --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/configure/wizard/wizard.less @@ -0,0 +1,9 @@ +cloudrun-load-balancer-basic-settings { + a.btn.btn-link { + padding: 0; + } + + .form-group { + margin-top: 0.4rem; + } +} diff --git a/packages/cloudrun/src/loadBalancer/details/details.controller.ts b/packages/cloudrun/src/loadBalancer/details/details.controller.ts new file mode 100644 index 00000000000..d0555e70b45 --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/details/details.controller.ts @@ -0,0 +1,140 @@ +import type { StateService } from '@uirouter/angularjs'; +import type { IController, IScope } from 'angular'; +import { module } from 'angular'; +import type { IModalService } from 'angular-ui-bootstrap'; +import { cloneDeep } from 'lodash'; + +import type { Application, ILoadBalancer, ILoadBalancerDeleteCommand } from '@spinnaker/core'; +import { ConfirmationModalService, LoadBalancerWriter } from '@spinnaker/core'; +import type { ICloudrunLoadBalancer } from '../../common/domain/index'; + +interface ILoadBalancerFromStateParams { + accountId: string; + region: string; + name: string; +} + +class CloudrunLoadBalancerDetailsController implements IController { + public state = { loading: true }; + private loadBalancerFromParams: ILoadBalancerFromStateParams; + public loadBalancer: ICloudrunLoadBalancer; + + public static $inject = ['$uibModal', '$state', '$scope', 'loadBalancer', 'app']; + constructor( + private $uibModal: IModalService, + private $state: StateService, + private $scope: IScope, + loadBalancer: ILoadBalancerFromStateParams, + private app: Application, + ) { + this.loadBalancerFromParams = loadBalancer; + this.app + .getDataSource('loadBalancers') + .ready() + .then(() => this.extractLoadBalancer()); + } + + // edit loadbalancer to change traffic + + public editLoadBalancer(): void { + this.$uibModal.open({ + templateUrl: require('../configure/wizard/wizard.html'), + controller: 'cloudrunLoadBalancerWizardCtrl as ctrl', + size: 'lg', + resolve: { + application: () => this.app, + loadBalancer: () => cloneDeep(this.loadBalancer), + isNew: () => false, + forPipelineConfig: () => false, + }, + }); + } + + private extractLoadBalancer(): void { + this.loadBalancer = this.app.getDataSource('loadBalancers').data.find((test: ILoadBalancer) => { + return test.name === this.loadBalancerFromParams.name && test.account === this.loadBalancerFromParams.accountId; + }) as ICloudrunLoadBalancer; + + if (this.loadBalancer) { + this.state.loading = false; + this.app.getDataSource('loadBalancers').onRefresh(this.$scope, () => this.extractLoadBalancer()); + } else { + this.autoClose(); + } + } + + public deleteLoadBalancer(): void { + const taskMonitor = { + application: this.app, + title: 'Deleting ' + this.loadBalancer.name, + }; + + const submitMethod = () => { + const loadBalancer: ILoadBalancerDeleteCommand = { + cloudProvider: this.loadBalancer.cloudProvider, + loadBalancerName: this.loadBalancer.name, + credentials: this.loadBalancer.account, + }; + return LoadBalancerWriter.deleteLoadBalancer(loadBalancer, this.app); + }; + + ConfirmationModalService.confirm({ + header: 'Really delete ' + this.loadBalancer.name + '?', + buttonText: 'Delete ' + this.loadBalancer.name, + body: this.getConfirmationModalBodyHtml(), + account: this.loadBalancer.account, + taskMonitorConfig: taskMonitor, + submitMethod, + }); + } + + public canDeleteLoadBalancer(): boolean { + return this.loadBalancer.name !== 'default'; + } + + private getConfirmationModalBodyHtml(): string { + const serverGroupNames = this.loadBalancer.serverGroups.map((serverGroup) => serverGroup.name); + const hasAny = serverGroupNames ? serverGroupNames.length > 0 : false; + const hasMoreThanOne = serverGroupNames ? serverGroupNames.length > 1 : false; + + // HTML accepted by the confirmationModalService is static (i.e., not managed by angular). + if (hasAny) { + if (hasMoreThanOne) { + const listOfServerGroupNames = serverGroupNames.map((name) => `
  • ${name}
  • `).join(''); + return `
    +

    + Deleting ${this.loadBalancer.name} will destroy the following server groups: +

      + ${listOfServerGroupNames} +
    +

    +
    + `; + } else { + return `
    +

    + Deleting ${this.loadBalancer.name} will destroy ${serverGroupNames[0]}. +

    +
    + `; + } + } else { + return null; + } + } + + private autoClose(): void { + if (this.$scope.$$destroyed) { + return; + } else { + this.$state.params.allowModalToStayOpen = true; + this.$state.go('^', null, { location: 'replace' }); + } + } +} + +export const CLOUDRUN_LOAD_BALANCER_DETAILS_CTRL = 'spinnaker.cloudrun.loadBalancerDetails.controller'; +module(CLOUDRUN_LOAD_BALANCER_DETAILS_CTRL, []).controller( + 'cloudrunLoadBalancerDetailsCtrl', + CloudrunLoadBalancerDetailsController, +); diff --git a/packages/cloudrun/src/loadBalancer/details/details.html b/packages/cloudrun/src/loadBalancer/details/details.html new file mode 100644 index 00000000000..0b6eb57b28c --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/details/details.html @@ -0,0 +1,86 @@ +
    +
    +
    + + + +
    +
    + +
    +
    + +
    +
    + + + +
    +
    + +

    {{ctrl.loadBalancer.name}}

    +
    +
    +
    + +
    +
    +
    + +
    + +
    +
    In
    +
    +
    Region
    +
    {{ctrl.loadBalancer.region}}
    +
    Server Groups
    +
    + +
    +
    +
    + +
    +
      +
    • + {{trafficTarget.revisionName}}:{{trafficTarget.percent}} +
    • +
    +
    +
    + +
    + +
    +
    +
    +
    diff --git a/packages/cloudrun/src/loadBalancer/index.ts b/packages/cloudrun/src/loadBalancer/index.ts new file mode 100644 index 00000000000..d2cf8b08313 --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/index.ts @@ -0,0 +1,3 @@ +export * from './loadBalancerTransformer'; +export * from './details/details.controller'; +export * from './configure/wizard/wizard.controller'; diff --git a/packages/cloudrun/src/loadBalancer/loadBalancerTransformer.ts b/packages/cloudrun/src/loadBalancer/loadBalancerTransformer.ts new file mode 100644 index 00000000000..ed1be0485cc --- /dev/null +++ b/packages/cloudrun/src/loadBalancer/loadBalancerTransformer.ts @@ -0,0 +1,168 @@ +import { module } from 'angular'; +import { camelCase, chain, cloneDeep, filter, get, has, reduce } from 'lodash'; + +import type { + Application, + IInstance, + IInstanceCounts, + ILoadBalancer, + ILoadBalancerUpsertCommand, + IServerGroup, +} from '@spinnaker/core'; +//import type { ICloudrunLoadBalancer, ICloudrunTrafficSplit, ShardBy } from '../common/domain/index'; +import type { ICloudrunLoadBalancer, ICloudrunTrafficSplit } from '../common/domain/index'; + +export interface ICloudrunAllocationDescription { + revisionName?: string; + target?: string; + cluster?: string; + percent: number; +} + +export interface ICloudrunTrafficSplitDescription { + allocationDescriptions: ICloudrunAllocationDescription[]; +} + +export class CloudrunLoadBalancerUpsertDescription implements ILoadBalancerUpsertCommand, ICloudrunLoadBalancer { + public credentials: string; + public account: string; + public loadBalancerName: string; + public name: string; + public splitDescription: ICloudrunTrafficSplitDescription; + public split?: ICloudrunTrafficSplit; + public migrateTraffic: boolean; + public region: string; + public cloudProvider: string; + public serverGroups?: any[]; + + public static convertTrafficSplitToTrafficSplitDescription( + split: ICloudrunTrafficSplit, + ): ICloudrunTrafficSplitDescription { + const allocationDescriptions = reduce( + split.trafficTargets, + (acc: any, trafficTarget: any) => { + const { revisionName, percent } = trafficTarget; + return acc.concat({ percent, revisionName, locatorType: 'fromExisting' }); + }, + [], + ); + return { allocationDescriptions }; + } + + constructor(loadBalancer: ICloudrunLoadBalancer) { + this.credentials = loadBalancer.account || loadBalancer.credentials; + this.account = this.credentials; + this.cloudProvider = loadBalancer.cloudProvider; + this.loadBalancerName = loadBalancer.name; + this.name = loadBalancer.name; + this.region = loadBalancer.region; + this.migrateTraffic = loadBalancer.migrateTraffic || false; + this.serverGroups = loadBalancer.serverGroups; + } + + public mapAllocationsToDecimals() { + this.splitDescription.allocationDescriptions.forEach((description) => { + description.percent = description.percent / 100; + }); + } + + public mapAllocationsToPercentages() { + this.splitDescription.allocationDescriptions.forEach((description) => { + // An allocation percent has at most one decimal place. + description.percent = Math.round(description.percent); + }); + } +} + +export class CloudrunLoadBalancerTransformer { + public static $inject = ['$q']; + constructor(private $q: ng.IQService) {} + public normalizeLoadBalancer(loadBalancer: ILoadBalancer): PromiseLike { + loadBalancer.provider = loadBalancer.type; + loadBalancer.instanceCounts = this.buildInstanceCounts(loadBalancer.serverGroups); + loadBalancer.instances = []; + loadBalancer.serverGroups.forEach((serverGroup) => { + serverGroup.account = loadBalancer.account; + serverGroup.region = loadBalancer.region; + + if (serverGroup.detachedInstances) { + serverGroup.detachedInstances = (serverGroup.detachedInstances as any).map((id: string) => ({ id })); + } + serverGroup.instances = serverGroup.instances + .concat(serverGroup.detachedInstances || []) + .map((instance: any) => this.transformInstance(instance, loadBalancer)); + }); + + const activeServerGroups = filter(loadBalancer.serverGroups, { isDisabled: false }); + loadBalancer.instances = chain(activeServerGroups).map('instances').flatten().value() as IInstance[]; + return this.$q.resolve(loadBalancer); + } + + public convertLoadBalancerForEditing( + loadBalancer: ICloudrunLoadBalancer, + application: Application, + ): PromiseLike { + return application + .getDataSource('loadBalancers') + .ready() + .then(() => { + const upToDateLoadBalancer = application + .getDataSource('loadBalancers') + .data.find((candidate: ILoadBalancer) => { + return ( + candidate.name === loadBalancer.name && + (candidate.account === loadBalancer.account || candidate.account === loadBalancer.credentials) + ); + }); + + if (upToDateLoadBalancer) { + loadBalancer.serverGroups = cloneDeep(upToDateLoadBalancer.serverGroups); + } + return loadBalancer; + }); + } + + public convertLoadBalancerToUpsertDescription( + loadBalancer: ICloudrunLoadBalancer, + ): CloudrunLoadBalancerUpsertDescription { + return new CloudrunLoadBalancerUpsertDescription(loadBalancer); + } + + private buildInstanceCounts(serverGroups: IServerGroup[]): IInstanceCounts { + const instanceCounts: IInstanceCounts = chain(serverGroups) + .map('instances') + .flatten() + .reduce( + (acc: IInstanceCounts, instance: any) => { + if (has(instance, 'health.state')) { + acc[camelCase(instance.health.state)]++; + } + return acc; + }, + { up: 0, down: 0, outOfService: 0, succeeded: 0, failed: 0, starting: 0, unknown: 0 }, + ) + .value(); + + instanceCounts.outOfService += chain(serverGroups).map('detachedInstances').flatten().value().length; + return instanceCounts; + } + + private transformInstance(instance: any, loadBalancer: ILoadBalancer) { + instance.provider = loadBalancer.type; + instance.account = loadBalancer.account; + instance.region = loadBalancer.region; + instance.loadBalancers = [loadBalancer.name]; + const health = instance.health || {}; + instance.healthState = get(instance, 'health.state') || 'OutOfService'; + instance.health = [health]; + + return instance as IInstance; + } +} + +export const CLOUDRUN_LOAD_BALANCER_TRANSFORMER = 'spinnaker.cloudrun.loadBalancer.transformer.service'; + +module(CLOUDRUN_LOAD_BALANCER_TRANSFORMER, []).service( + 'cloudrunLoadBalancerTransformer', + CloudrunLoadBalancerTransformer, +); diff --git a/packages/cloudrun/src/logo/cloudrun.icon.svg b/packages/cloudrun/src/logo/cloudrun.icon.svg new file mode 100644 index 00000000000..91d2c6dc124 --- /dev/null +++ b/packages/cloudrun/src/logo/cloudrun.icon.svg @@ -0,0 +1,27 @@ + + + + + Icon_24px_CloudRun_Color + + + + + + + + + \ No newline at end of file diff --git a/packages/cloudrun/src/logo/cloudrun.logo.less b/packages/cloudrun/src/logo/cloudrun.logo.less new file mode 100644 index 00000000000..914b571a583 --- /dev/null +++ b/packages/cloudrun/src/logo/cloudrun.logo.less @@ -0,0 +1,6 @@ +.cloud-provider-logo { + .icon-cloudrun { + mask-image: url(cloudrun.icon.svg); + background-color: #4285f4; + } +} diff --git a/packages/cloudrun/src/logo/cloudrun.logo.png b/packages/cloudrun/src/logo/cloudrun.logo.png new file mode 100644 index 00000000000..423cf5cb794 Binary files /dev/null and b/packages/cloudrun/src/logo/cloudrun.logo.png differ diff --git a/packages/cloudrun/src/pipeline/pipeline.module.ts b/packages/cloudrun/src/pipeline/pipeline.module.ts new file mode 100644 index 00000000000..203bdc9f84b --- /dev/null +++ b/packages/cloudrun/src/pipeline/pipeline.module.ts @@ -0,0 +1,6 @@ +import { module } from 'angular'; + +import { CLOUDRUN_EDIT_LOAD_BALANCER_STAGE } from './stages/editLoadBalancer/cloudrunEditLoadBalancerStage'; + +export const CLOUDRUN_PIPELINE_MODULE = 'spinnaker.cloudrun.pipeline.module'; +module(CLOUDRUN_PIPELINE_MODULE, [CLOUDRUN_EDIT_LOAD_BALANCER_STAGE]); diff --git a/packages/cloudrun/src/pipeline/stages/editLoadBalancer/cloudrunEditLoadBalancerStage.ts b/packages/cloudrun/src/pipeline/stages/editLoadBalancer/cloudrunEditLoadBalancerStage.ts new file mode 100644 index 00000000000..cbddfdb92d8 --- /dev/null +++ b/packages/cloudrun/src/pipeline/stages/editLoadBalancer/cloudrunEditLoadBalancerStage.ts @@ -0,0 +1,74 @@ +import type { IController } from 'angular'; +import { module } from 'angular'; +import type { IModalService } from 'angular-ui-bootstrap'; +import { cloneDeep } from 'lodash'; + +import type { ILoadBalancer } from '@spinnaker/core'; +import { CloudProviderRegistry, Registry } from '@spinnaker/core'; + +import { CLOUDRUN_LOAD_BALANCER_CHOICE_MODAL_CTRL } from './loadBalancerChoice.modal.controller'; + +class CloudrunEditLoadBalancerStageCtrl implements IController { + public static $inject = ['$scope', '$uibModal']; + constructor(public $scope: any, private $uibModal: IModalService) { + $scope.stage.loadBalancers = $scope.stage.loadBalancers || []; + $scope.stage.cloudProvider = 'cloudrun'; + } + + public addLoadBalancer(): void { + this.$uibModal + .open({ + templateUrl: require('./loadBalancerChoice.modal.html'), + controller: `cloudrunLoadBalancerChoiceModelCtrl as ctrl`, + resolve: { + application: () => this.$scope.application, + }, + }) + .result.then((newLoadBalancer: ILoadBalancer) => { + this.$scope.stage.loadBalancers.push(newLoadBalancer); + }) + .catch(() => {}); + } + + public editLoadBalancer(index: number) { + const config = CloudProviderRegistry.getValue('cloudrun', 'loadBalancer'); + this.$uibModal + .open({ + templateUrl: config.createLoadBalancerTemplateUrl, + controller: `${config.createLoadBalancerController} as ctrl`, + size: 'lg', + resolve: { + application: () => this.$scope.application, + loadBalancer: () => cloneDeep(this.$scope.stage.loadBalancers[index]), + isNew: () => false, + forPipelineConfig: () => true, + }, + }) + .result.then((updatedLoadBalancer: ILoadBalancer) => { + this.$scope.stage.loadBalancers[index] = updatedLoadBalancer; + }) + .catch(() => {}); + } + + public removeLoadBalancer(index: number): void { + this.$scope.stage.loadBalancers.splice(index, 1); + } +} + +export const CLOUDRUN_EDIT_LOAD_BALANCER_STAGE = 'spinnaker.cloudrun.pipeline.stage.editLoadBalancerStage'; +module(CLOUDRUN_EDIT_LOAD_BALANCER_STAGE, [CLOUDRUN_LOAD_BALANCER_CHOICE_MODAL_CTRL]) + .config(() => { + Registry.pipeline.registerStage({ + label: 'Edit Load Balancer (Cloudrun)', + description: 'Edits a load balancer', + key: 'upsertCloudrunLoadBalancers', + cloudProvider: 'cloudrun', + templateUrl: require('./editLoadBalancerStage.html'), + executionDetailsUrl: require('./editLoadBalancerExecutionDetails.html'), + executionConfigSections: ['editLoadBalancerConfig', 'taskStatus'], + controller: 'cloudrunEditLoadBalancerStageCtrl', + controllerAs: 'editLoadBalancerStageCtrl', + validators: [], + }); + }) + .controller('cloudrunEditLoadBalancerStageCtrl', CloudrunEditLoadBalancerStageCtrl); diff --git a/packages/cloudrun/src/pipeline/stages/editLoadBalancer/editLoadBalancerExecutionDetails.html b/packages/cloudrun/src/pipeline/stages/editLoadBalancer/editLoadBalancerExecutionDetails.html new file mode 100644 index 00000000000..1ee8b1224b4 --- /dev/null +++ b/packages/cloudrun/src/pipeline/stages/editLoadBalancer/editLoadBalancerExecutionDetails.html @@ -0,0 +1,33 @@ +
    + +
    +
    +
    + + + + + + + + + + + + + + + +
    AccountNameRegion
    + + {{ loadBalancer.name }}{{ loadBalancer.region }}
    +
    +
    + +
    +
    +
    + +
    +
    +
    diff --git a/packages/cloudrun/src/pipeline/stages/editLoadBalancer/editLoadBalancerStage.html b/packages/cloudrun/src/pipeline/stages/editLoadBalancer/editLoadBalancerStage.html new file mode 100644 index 00000000000..0772bd9d571 --- /dev/null +++ b/packages/cloudrun/src/pipeline/stages/editLoadBalancer/editLoadBalancerStage.html @@ -0,0 +1,51 @@ +
    +
    +
    +

    Load Balancers

    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + +
    AccountNameRegionActions
    + + {{ loadBalancer.name }}{{ loadBalancer.region }} + + + + + +
    + +
    +
    +
    +
    diff --git a/packages/cloudrun/src/pipeline/stages/editLoadBalancer/loadBalancerChoice.modal.controller.ts b/packages/cloudrun/src/pipeline/stages/editLoadBalancer/loadBalancerChoice.modal.controller.ts new file mode 100644 index 00000000000..7daa4cd2194 --- /dev/null +++ b/packages/cloudrun/src/pipeline/stages/editLoadBalancer/loadBalancerChoice.modal.controller.ts @@ -0,0 +1,65 @@ +import type { IController } from 'angular'; +import { module } from 'angular'; +import type { IModalService, IModalServiceInstance } from 'angular-ui-bootstrap'; +import { cloneDeep } from 'lodash'; + +import type { Application, ILoadBalancer } from '@spinnaker/core'; +import { CloudProviderRegistry } from '@spinnaker/core'; + +class CloudrunLoadBalancerChoiceModalCtrl implements IController { + public state = { loading: true }; + public loadBalancers: ILoadBalancer[]; + public selectedLoadBalancer: ILoadBalancer; + + public static $inject = ['$uibModal', '$uibModalInstance', 'application']; + constructor( + private $uibModal: IModalService, + private $uibModalInstance: IModalServiceInstance, + private application: Application, + ) { + this.initialize(); + } + + public submit(): void { + const config = CloudProviderRegistry.getValue('cloudrun', 'loadBalancer'); + const updatedLoadBalancerPromise = this.$uibModal.open({ + templateUrl: config.createLoadBalancerTemplateUrl, + controller: `${config.createLoadBalancerController} as ctrl`, + size: 'lg', + resolve: { + application: () => this.application, + loadBalancer: () => cloneDeep(this.selectedLoadBalancer), + isNew: () => false, + forPipelineConfig: () => true, + }, + }).result; + + this.$uibModalInstance.close(updatedLoadBalancerPromise); + } + + public cancel(): void { + this.$uibModalInstance.dismiss(); + } + + private initialize(): void { + this.application + .getDataSource('loadBalancers') + .ready() + .then(() => { + this.loadBalancers = (this.application.loadBalancers.data as ILoadBalancer[]).filter( + (candidate) => candidate.cloudProvider === 'cloudrun', + ); + + if (this.loadBalancers.length) { + this.selectedLoadBalancer = this.loadBalancers[0]; + } + this.state.loading = false; + }); + } +} + +export const CLOUDRUN_LOAD_BALANCER_CHOICE_MODAL_CTRL = 'spinnaker.Cloudrun.loadBalancerChoiceModal.controller'; +module(CLOUDRUN_LOAD_BALANCER_CHOICE_MODAL_CTRL, []).controller( + 'cloudrunLoadBalancerChoiceModelCtrl', + CloudrunLoadBalancerChoiceModalCtrl, +); diff --git a/packages/cloudrun/src/pipeline/stages/editLoadBalancer/loadBalancerChoice.modal.html b/packages/cloudrun/src/pipeline/stages/editLoadBalancer/loadBalancerChoice.modal.html new file mode 100644 index 00000000000..7d1faf64e35 --- /dev/null +++ b/packages/cloudrun/src/pipeline/stages/editLoadBalancer/loadBalancerChoice.modal.html @@ -0,0 +1,53 @@ +
    + + + + + +
    diff --git a/packages/cloudrun/src/serverGroup/configure/serverGroupCommandBuilder.service.ts b/packages/cloudrun/src/serverGroup/configure/serverGroupCommandBuilder.service.ts new file mode 100644 index 00000000000..dd3629d3a5d --- /dev/null +++ b/packages/cloudrun/src/serverGroup/configure/serverGroupCommandBuilder.service.ts @@ -0,0 +1,254 @@ +import { module } from 'angular'; +import { cloneDeep } from 'lodash'; +import { $q } from 'ngimport'; + +import type { + Application, + IAccountDetails, + IExpectedArtifact, + IMoniker, + IPipeline, + IServerGroupCommand, + IServerGroupCommandViewState, + IStage, +} from '@spinnaker/core'; +import { AccountService } from '@spinnaker/core'; + +import { CloudrunProviderSettings } from '../../cloudrun.settings'; +import type { CloudrunDeployDescription } from '../serverGroupTransformer.service'; + +export enum ServerGroupSource { + TEXT = 'text', + ARTIFACT = 'artifact', +} + +export interface ICloudrunServerGroupCommandData { + command: ICloudrunServerGroupCommand; + metadata: ICloudrunServerGroupCommandMetadata; + stack: string; + freeFormDetails: string; + configFiles: string[]; +} + +export interface ICloudrunServerGroupCommand extends Omit { + application?: string; + stack?: string; + detail?: string; + account: string; + configFiles: string[]; + freeFormDetails: string; + region: string; + regions: []; + isNew?: boolean; + cloudProvider: string; + provider: string; + selectedProvider: string; + manifest: any; // deprecated + manifests: any[]; + relationships: ICloudrunServerGroupSpinnakerRelationships; + moniker: IMoniker; + manifestArtifactId?: string; + manifestArtifactAccount?: string; + source: ServerGroupSource; + versioned?: boolean; + gitCredentialType?: string; + viewState: IServerGroupCommandViewState; + mode: string; + credentials: string; + sourceType: string; + configArtifacts: any[]; + interestingHealthProviderNames: []; + fromArtifact: boolean; +} + +export interface IViewState { + mode: string; + submitButtonLabel: string; + disableStrategySelection: boolean; + stage?: IStage; + pipeline?: IPipeline; +} + +export interface ICloudrunServerGroupCommandMetadata { + backingData: any; +} + +export interface ICloudrunServerGroupSpinnakerRelationships { + loadBalancers?: string[]; + securityGroups?: string[]; +} + +const getSubmitButtonLabel = (mode: string): string => { + switch (mode) { + case 'createPipeline': + return 'Add'; + case 'editPipeline': + return 'Done'; + default: + return 'Create'; + } +}; + +export class CloudrunV2ServerGroupCommandBuilder { + // new add servergroup + public buildNewServerGroupCommand(app: Application): PromiseLike { + return CloudrunServerGroupCommandBuilder.buildNewServerGroupCommand(app, 'cloudrun', 'create'); + } + + // add servergroup from deploy stage of pipeline + public buildNewServerGroupCommandForPipeline(_stage: IStage, pipeline: IPipeline) { + return CloudrunServerGroupCommandBuilder.buildNewServerGroupCommandForPipeline(_stage, pipeline); + } + + // edit servergroup from deploy stage of pipeline + // add servergroup from deploy stage of pipeline + public buildServerGroupCommandFromPipeline( + app: Application, + cluster: CloudrunDeployDescription, + _stage: IStage, + pipeline: IPipeline, + ) { + return CloudrunServerGroupCommandBuilder.buildServerGroupCommandFromPipeline(app, cluster, _stage, pipeline); + } +} + +export class CloudrunServerGroupCommandBuilder { + public static $inject = ['$q']; + public static ServerGroupCommandIsValid(command: ICloudrunServerGroupCommand): boolean { + if (!command.moniker) { + return false; + } + + if (!command.moniker.app) { + return false; + } + + return true; + } + + public static copyAndCleanCommand(input: ICloudrunServerGroupCommand): ICloudrunServerGroupCommand { + const command = cloneDeep(input); + return command; + } + + // deploy stage : construct servergroup command + public static buildNewServerGroupCommandForPipeline(stage: IStage, pipeline: IPipeline): any { + const command: any = this.buildNewServerGroupCommand( + { name: pipeline.application } as Application, + 'cloudrun', + 'createPipeline', + ); + command.viewState = { + ...command.viewState, + pipeline, + requiresTemplateSelection: true, + stage, + }; + return command; + } + + private static getExpectedArtifacts(pipeline: IPipeline): IExpectedArtifact[] { + return pipeline.expectedArtifacts || []; + } + + public static buildServerGroupCommandFromPipeline( + app: Application, + cluster: CloudrunDeployDescription, + _stage: IStage, + pipeline: IPipeline, + ): PromiseLike { + return CloudrunServerGroupCommandBuilder.buildNewServerGroupCommand(app, 'cloudrun', 'editPipeline').then( + (command: ICloudrunServerGroupCommandData) => { + command = { + ...command, + ...cluster, + + backingData: { + ...command.metadata.backingData.backingData, + + expectedArtifacts: CloudrunServerGroupCommandBuilder.getExpectedArtifacts(pipeline), + }, + credentials: cluster.account || command.metadata.backingData.credentials, + viewState: { + ...command.metadata.backingData.viewState, + stage: _stage, + pipeline, + }, + } as ICloudrunServerGroupCommandData; + return command; + }, + ); + } + + public static getCredentials(accounts: IAccountDetails[]): string { + const accountNames: string[] = (accounts || []).map((account) => account.name); + const defaultCredentials: string = CloudrunProviderSettings.defaults.account; + + return accountNames.includes(defaultCredentials) ? defaultCredentials : accountNames[0]; + } + + public static getRegion(accounts: any[], credentials: string): string { + const account = accounts.find((_account) => _account.name === credentials); + return account ? account.region : null; + } + + // new servergroup command + public static buildNewServerGroupCommand( + app: Application, + sourceAccount: string, + mode: string, + ): PromiseLike { + const dataToFetch = { + accounts: AccountService.getAllAccountDetailsForProvider('cloudrun'), + artifactAccounts: AccountService.getArtifactAccounts(), + }; + + return $q.all(dataToFetch).then((backingData: { accounts: IAccountDetails[] }) => { + const { accounts } = backingData; + + const account = accounts.some((a) => a.name === sourceAccount) + ? accounts.find((a) => a.name === sourceAccount).name + : accounts.length + ? accounts[0].name + : null; + const viewState: IViewState = { + mode, + submitButtonLabel: getSubmitButtonLabel(mode), + disableStrategySelection: mode === 'create', + }; + const credentials = account ? account : this.getCredentials(accounts); + const region = this.getRegion(backingData.accounts, credentials); + const cloudProvider = 'cloudrun'; + + return { + command: { + application: app.name, + configFiles: [''], + cloudProvider, + selectedProvider: cloudProvider, + provider: cloudProvider, + region, + credentials, + gitCredentialType: 'NONE', + manifest: null, + sourceType: 'git', + configArtifacts: [], + interestingHealthProviderNames: [], + fromArtifact: false, + account, + viewState, + }, + metadata: { + backingData, + }, + } as ICloudrunServerGroupCommandData; + }); + } +} + +export const CLOUDRUN_SERVER_GROUP_COMMAND_BUILDER = 'spinnaker.cloudrun.serverGroup.commandBuilder.service'; + +module(CLOUDRUN_SERVER_GROUP_COMMAND_BUILDER, []).service( + 'cloudrunV2ServerGroupCommandBuilder', + CloudrunV2ServerGroupCommandBuilder, +); diff --git a/packages/cloudrun/src/serverGroup/configure/wizard/BasicSettings.tsx b/packages/cloudrun/src/serverGroup/configure/wizard/BasicSettings.tsx new file mode 100644 index 00000000000..9a37499fd31 --- /dev/null +++ b/packages/cloudrun/src/serverGroup/configure/wizard/BasicSettings.tsx @@ -0,0 +1,160 @@ +import type { FormikProps } from 'formik'; +import React from 'react'; + +import type { Application, IAccount, IServerGroup } from '@spinnaker/core'; +import { AccountSelectInput, HelpField, NameUtils, ReactInjector, ServerGroupNamePreview } from '@spinnaker/core'; + +import type { ICloudrunServerGroupCommandData } from '../serverGroupCommandBuilder.service'; + +export interface IServerGroupBasicSettingsProps { + accounts: IAccount[]; + onAccountSelect: (account: string) => void; + selectedAccount: string; + formik: IWizardServerGroupBasicSettingsProps['formik']; + onEnterStack: (stack: string) => void; + detailsChanged: (detail: string) => void; + app: Application; +} + +export interface IServerGroupBasicSettingsState { + namePreview: string; + createsNewCluster: boolean; + latestServerGroup: IServerGroup; +} + +export function ServerGroupBasicSettings({ + accounts, + onAccountSelect, + selectedAccount, + formik, + onEnterStack, + detailsChanged, + app, +}: IServerGroupBasicSettingsProps) { + const { values } = formik; + const { stack = '', freeFormDetails } = values; + + const namePreview = NameUtils.getClusterName(app.name, stack, freeFormDetails); + const createsNewCluster = !app.clusters.find((c) => c.name === namePreview); + const inCluster = (app.serverGroups.data as IServerGroup[]) + .filter((serverGroup) => { + return ( + serverGroup.cluster === namePreview && + serverGroup.account === values.command.credentials && + serverGroup.region === values.command.region + ); + }) + .sort((a, b) => a.createdTime - b.createdTime); + const latestServerGroup = inCluster.length ? inCluster.pop() : null; + + const navigateToLatestServerGroup = () => { + const { values } = formik; + const params = { + provider: values.command.selectedProvider, + accountId: latestServerGroup.account, + region: latestServerGroup.region, + serverGroup: latestServerGroup.name, + }; + + const { $state } = ReactInjector; + if ($state.is('home.applications.application.insight.clusters')) { + $state.go('.serverGroup', params); + } else { + $state.go('^.serverGroup', params); + } + }; + + return ( +
    +
    +
    Account
    +
    + onAccountSelect(evt.target.value)} + readOnly={false} + accounts={accounts} + provider="cloudrun" + /> +
    +
    + +
    +
    + Stack +
    +
    + onEnterStack(e.target.value)} + /> +
    +
    + +
    +
    + Detail +
    +
    + detailsChanged(e.target.value)} + /> +
    +
    + {!values.command.viewState.hideClusterNamePreview && ( + + )} +
    + ); +} + +export interface IWizardServerGroupBasicSettingsProps { + formik: FormikProps; + app: Application; +} + +export class WizardServerGroupBasicSettings extends React.Component { + private accountUpdated = (account: string): void => { + const { formik } = this.props; + formik.values.command.account = account; + formik.setFieldValue('account', account); + }; + + private stackChanged = (stack: string): void => { + const { setFieldValue, values } = this.props.formik; + values.command.stack = stack; + setFieldValue('stack', stack); + }; + + private freeFormDetailsChanged = (freeFormDetails: string) => { + const { setFieldValue, values } = this.props.formik; + values.command.freeFormDetails = freeFormDetails; + setFieldValue('freeFormDetails', freeFormDetails); + }; + + public render() { + const { formik, app } = this.props; + return ( + + ); + } +} diff --git a/packages/cloudrun/src/serverGroup/configure/wizard/ConfigFiles.tsx b/packages/cloudrun/src/serverGroup/configure/wizard/ConfigFiles.tsx new file mode 100644 index 00000000000..330f0dc407d --- /dev/null +++ b/packages/cloudrun/src/serverGroup/configure/wizard/ConfigFiles.tsx @@ -0,0 +1,75 @@ +import type { FormikProps } from 'formik'; +import React, { useState } from 'react'; + +import { HelpField, TextAreaInput } from '@spinnaker/core'; +import type { ICloudrunServerGroupCommandData } from '../serverGroupCommandBuilder.service'; + +export interface IServerGroupConfigFilesSettingsProps { + configFiles: string[]; + onEnterConfig: (file: string[]) => void; +} +type configFiles = IServerGroupConfigFilesSettingsProps['configFiles']; + +export function ServerGroupConfigFilesSettings({ configFiles, onEnterConfig }: IServerGroupConfigFilesSettingsProps) { + const [configValues, setConfigValues] = useState(configFiles); + + function mapTabToSpaces(event: any, i: number) { + if (event.which === 9) { + event.preventDefault(); + const cursorPosition = event.target.selectionStart; + const inputValue = event.target.value; + event.target.value = `${inputValue.substring(0, cursorPosition)} ${inputValue.substring(cursorPosition)}`; + event.target.selectionStart += 2; + } + const newConfigValues = [...configValues]; + newConfigValues[i] = event.target.value; + setConfigValues(newConfigValues); + onEnterConfig(newConfigValues); + } + + return ( +
    +
    + {configValues.map((configFile, index) => ( + <> +
    + Service Yaml + {' '} +
    +
    + mapTabToSpaces(e, index)} + /> +
    + + ))} +
    +
    + ); +} + +export interface IWizardServerGroupConfigFilesSettingsProps { + formik: FormikProps; +} + +export class WizardServerGroupConfigFilesSettings extends React.Component { + private configUpdated = (configFiles: string[]): void => { + const { formik } = this.props; + formik.values.command.configFiles = configFiles; + formik.setFieldValue('configFiles', configFiles); + }; + + // yaml config files input from server group wizard + public render() { + const { formik } = this.props; + return ( + + ); + } +} diff --git a/packages/cloudrun/src/serverGroup/configure/wizard/serverGroupWizard.tsx b/packages/cloudrun/src/serverGroup/configure/wizard/serverGroupWizard.tsx new file mode 100644 index 00000000000..3877f49aac4 --- /dev/null +++ b/packages/cloudrun/src/serverGroup/configure/wizard/serverGroupWizard.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import type { Application, IModalComponentProps, IStage } from '@spinnaker/core'; +//import type { IModalInstanceService } from 'angular-ui-bootstrap'; +import { noop, ReactInjector, ReactModal, TaskMonitor, WizardModal, WizardPage } from '@spinnaker/core'; +import { WizardServerGroupBasicSettings } from './BasicSettings'; +import { WizardServerGroupConfigFilesSettings } from './ConfigFiles'; +import type { ICloudrunServerGroupCommandData } from '../serverGroupCommandBuilder.service'; +import { CloudrunServerGroupCommandBuilder } from '../serverGroupCommandBuilder.service'; + +export interface ICloudrunServerGroupModalProps extends IModalComponentProps { + title: string; + application: Application; + command: ICloudrunServerGroupCommandData; + isNew?: boolean; +} + +export interface ICloudrunServerGroupModalState { + command: ICloudrunServerGroupCommandData; + loaded: boolean; + taskMonitor: TaskMonitor; +} + +export class ServerGroupWizard extends React.Component { + public static defaultProps: Partial = { + closeModal: noop, + dismissModal: noop, + }; + + private _isUnmounted = false; + + /* private serverGroupWriter: ServerGroupWriter; */ + public static show(props: ICloudrunServerGroupModalProps): Promise { + const modalProps = { dialogClassName: 'wizard-modal modal-lg' }; + return ReactModal.show(ServerGroupWizard, props, modalProps); + } + + constructor(props: ICloudrunServerGroupModalProps) { + super(props); + if (!props.command) { + CloudrunServerGroupCommandBuilder.buildNewServerGroupCommand(props.application, 'cloudrun', 'create').then( + (command) => { + Object.assign(this.state.command, command); + this.setState({ loaded: true }); + }, + ); + } + + this.state = { + loaded: !!props.command, + command: props.command || ({} as ICloudrunServerGroupCommandData), + taskMonitor: new TaskMonitor({ + application: props.application, + title: `${ + props.command.command.viewState.submitButtonLabel === 'Create' ? 'Creating' : 'Updating' + } your Server Group`, + modalInstance: TaskMonitor.modalInstanceEmulation(() => this.props.dismissModal()), + onTaskComplete: this.onTaskComplete, + }), + }; + } + + private onTaskComplete = () => { + this.props.application.serverGroups.refresh(); + this.props.application.serverGroups.onNextRefresh(null, this.onApplicationRefresh); + }; + + protected onApplicationRefresh = (): void => { + if (this._isUnmounted) { + return; + } + + const { command } = this.props; + const { taskMonitor } = this.state; + const cloneStage = taskMonitor.task.execution.stages.find((stage: IStage) => stage.type === 'cloneServerGroup'); + if (cloneStage && cloneStage.context['deploy.server.groups']) { + const newServerGroupName = cloneStage.context['deploy.server.groups'][command.command.region]; + if (newServerGroupName) { + const newStateParams = { + serverGroup: newServerGroupName, + accountId: command.command.credentials, + region: command.command.region, + provider: 'cloudrun', + }; + let transitionTo = '^.^.^.clusters.serverGroup'; + if (ReactInjector.$state.includes('**.clusters.serverGroup')) { + // clone via details, all view + transitionTo = '^.serverGroup'; + } + if (ReactInjector.$state.includes('**.clusters.cluster.serverGroup')) { + // clone or create with details open + transitionTo = '^.^.serverGroup'; + } + if (ReactInjector.$state.includes('**.clusters')) { + // create new, no details open + transitionTo = '.serverGroup'; + } + ReactInjector.$state.go(transitionTo, newStateParams); + } + } + }; + + private submit = (c: ICloudrunServerGroupCommandData): void => { + const command: any = CloudrunServerGroupCommandBuilder.copyAndCleanCommand(c.command); + const forPipelineConfig = command.viewState.mode === 'editPipeline' || command.viewState.mode === 'createPipeline'; + if (forPipelineConfig) { + this.props.closeModal && this.props.closeModal(command); + } else { + //command.viewState.mode = 'create'; + const submitMethod = () => ReactInjector.serverGroupWriter.cloneServerGroup(command, this.props.application); + this.state.taskMonitor.submit(submitMethod); + return null; + } + }; + public render() { + const { dismissModal, application } = this.props; + const { loaded, taskMonitor, command } = this.state; + const labelButton = this.state.command.command.viewState.submitButtonLabel; + + return ( + + heading={`${labelButton === 'Add' || labelButton === 'Create' ? 'Create New' : 'Update'} Server Group`} + initialValues={command} + loading={!loaded} + taskMonitor={taskMonitor} + dismissModal={dismissModal} + closeModal={this.submit} + submitButtonLabel={labelButton} + render={({ formik, nextIdx, wizard }) => ( + <> + ( + + )} + /> + + } + /> + + )} + /> + ); + } +} diff --git a/packages/cloudrun/src/serverGroup/details/details.controller.ts b/packages/cloudrun/src/serverGroup/details/details.controller.ts new file mode 100644 index 00000000000..6f1fd8ff7f5 --- /dev/null +++ b/packages/cloudrun/src/serverGroup/details/details.controller.ts @@ -0,0 +1,134 @@ +import type { IController, IScope } from 'angular'; +import { module } from 'angular'; +import type { Application, ILoadBalancer, IServerGroup, ServerGroupWriter } from '@spinnaker/core'; +import { ConfirmationModalService, SERVER_GROUP_WRITER, ServerGroupReader } from '@spinnaker/core'; +import { CloudrunHealth } from '../../common/cloudrunHealth'; +import type { ICloudrunServerGroup } from '../../interfaces'; + +interface IServerGroupFromStateParams { + accountId: string; + region: string; + name: string; +} + +class CloudrunServerGroupDetailsController implements IController { + public state = { loading: true }; + public serverGroup: ICloudrunServerGroup; + + public static $inject = ['$state', '$scope', 'serverGroup', 'app', 'serverGroupWriter']; + constructor( + private $state: any, + private $scope: IScope, + serverGroup: IServerGroupFromStateParams, + public app: Application, + private serverGroupWriter: ServerGroupWriter, + ) { + this.extractServerGroup(serverGroup) + .then(() => { + if (!this.$scope.$$destroyed) { + this.app.getDataSource('serverGroups').onRefresh(this.$scope, () => this.extractServerGroup(serverGroup)); + } + }) + .catch(() => this.autoClose()); + } + + // destroy existing server group + public canDestroyServerGroup(): boolean { + if (this.serverGroup) { + const isCurrentRevision = this.serverGroup.tags.isLatest; + if (isCurrentRevision) { + return false; + } else if (this.serverGroup.disabled) { + return true; + } else { + return false; + } + } else { + return false; + } + } + + public destroyServerGroup(): void { + const stateParams = { + name: this.serverGroup.name, + accountId: this.serverGroup.account, + region: this.serverGroup.region, + }; + + const taskMonitor = { + application: this.app, + title: 'Destroying ' + this.serverGroup.name, + onTaskComplete: () => { + if (this.$state.includes('**.serverGroup', stateParams)) { + this.$state.go('^'); + } + }, + }; + + const submitMethod = (params: any) => this.serverGroupWriter.destroyServerGroup(this.serverGroup, this.app, params); + + const confirmationModalParams = { + header: 'Really destroy ' + this.serverGroup.name + '?', + buttonText: 'Destroy ' + this.serverGroup.name, + account: this.serverGroup.account, + taskMonitorConfig: taskMonitor, + submitMethod, + askForReason: true, + platformHealthOnlyShowOverride: this.app.attributes.platformHealthOnlyShowOverride, + platformHealthType: CloudrunHealth.PLATFORM, + + interestingHealthProviderNames: [] as string[], + }; + + if (this.app.attributes.platformHealthOnlyShowOverride && this.app.attributes.platformHealthOnly) { + confirmationModalParams.interestingHealthProviderNames = [CloudrunHealth.PLATFORM]; + } + + ConfirmationModalService.confirm(confirmationModalParams); + } + + private autoClose(): void { + if (this.$scope.$$destroyed) { + return; + } else { + this.$state.params.allowModalToStayOpen = true; + this.$state.go('^', null, { location: 'replace' }); + } + } + + private extractServerGroup({ name, accountId, region }: IServerGroupFromStateParams): PromiseLike { + return ServerGroupReader.getServerGroup(this.app.name, accountId, region, name).then( + (serverGroupDetails: IServerGroup) => { + let fromApp = this.app.getDataSource('serverGroups').data.find((toCheck: IServerGroup) => { + return toCheck.name === name && toCheck.account === accountId && toCheck.region === region; + }); + + if (!fromApp) { + this.app.getDataSource('loadBalancers').data.some((loadBalancer: ILoadBalancer) => { + if (loadBalancer.account === accountId) { + return loadBalancer.serverGroups.some((toCheck: IServerGroup) => { + let result = false; + if (toCheck.name === name) { + fromApp = toCheck; + result = true; + } + return result; + }); + } else { + return false; + } + }); + } + + this.serverGroup = { ...serverGroupDetails, ...fromApp }; + this.state.loading = false; + }, + ); + } +} +export const CLOUDRUN_SERVER_GROUP_DETAILS_CTRL = 'spinnaker.cloudrun.serverGroup.details.controller'; + +module(CLOUDRUN_SERVER_GROUP_DETAILS_CTRL, [SERVER_GROUP_WRITER]).controller( + 'cloudrunV2ServerGroupDetailsCtrl', + CloudrunServerGroupDetailsController, +); diff --git a/packages/cloudrun/src/serverGroup/details/details.html b/packages/cloudrun/src/serverGroup/details/details.html new file mode 100644 index 00000000000..6bcd91474c2 --- /dev/null +++ b/packages/cloudrun/src/serverGroup/details/details.html @@ -0,0 +1,94 @@ +
    +
    +
    + + + +
    +

    + +

    +
    + +
    +
    + + + +
    +
    + +

    {{ctrl.serverGroup.name}}

    +
    +
    + +
    +
    +
    +
    Disabled
    + + +
    +
    Created
    +
    {{ctrl.serverGroup.createdTime | timestamp}}
    +
    In
    +
    +
    Region
    +
    {{ctrl.serverGroup.region}}
    +
    +
    + + +
    +
    Min/Max
    +
    {{ctrl.serverGroup.capacity.min}}
    +
    Current
    +
    {{ctrl.serverGroup.instances.length}}
    +
    +
    +
    Min
    +
    {{ctrl.serverGroup.capacity.min}}
    +
    Max
    +
    {{ctrl.serverGroup.capacity.max}}
    +
    Current
    +
    {{ctrl.serverGroup.instances.length}}
    +
    +
    + +
    +
    Instances
    +
    + +
    +
    +
    +
    +
    diff --git a/packages/cloudrun/src/serverGroup/index.ts b/packages/cloudrun/src/serverGroup/index.ts new file mode 100644 index 00000000000..4bfea14496c --- /dev/null +++ b/packages/cloudrun/src/serverGroup/index.ts @@ -0,0 +1,3 @@ +export * from './configure/serverGroupCommandBuilder.service'; +export * from './serverGroupTransformer.service'; +export * from './details/details.controller'; diff --git a/packages/cloudrun/src/serverGroup/serverGroupTransformer.service.ts b/packages/cloudrun/src/serverGroup/serverGroupTransformer.service.ts new file mode 100644 index 00000000000..577b54b0638 --- /dev/null +++ b/packages/cloudrun/src/serverGroup/serverGroupTransformer.service.ts @@ -0,0 +1,61 @@ +import { module } from 'angular'; +import type { IServerGroup } from '@spinnaker/core'; + +import type { ICloudrunServerGroupCommand } from '../serverGroup/configure/serverGroupCommandBuilder.service'; + +export class CloudrunV2ServerGroupTransformer { + public static $inject = ['$q']; + constructor(private $q: ng.IQService) {} + + public normalizeServerGroup(serverGroup: IServerGroup): PromiseLike { + return this.$q.resolve(serverGroup); + } + + public convertServerGroupCommandToDeployConfiguration(command: ICloudrunServerGroupCommand): any { + return new CloudrunDeployDescription(command); + } +} + +export class CloudrunDeployDescription { + public cloudProvider = 'cloudrun'; + public provider = 'cloudrun'; + public credentials: string; + public account: string; + public application: string; + public stack?: string; + public freeFormDetails?: string; + public configFiles: string[]; + public region: string; + public strategy?: string; + public type?: string; + public fromArtifact: boolean; + public configArtifacts: string[]; + public strategyApplication?: string; + public strategyPipeline?: string; + public gitCredentialType: string; + public interestingHealthProviderNames: string[]; + public sourceType: string; + + constructor(command: ICloudrunServerGroupCommand) { + this.credentials = command.credentials; + this.account = command.credentials; + this.application = command.application; + this.stack = command.stack; + this.freeFormDetails = command.freeFormDetails; + this.region = command.region; + this.strategy = command.strategy; + this.type = command.type; + this.fromArtifact = command.fromArtifact; + this.gitCredentialType = command.gitCredentialType; + this.configFiles = command.configFiles; + this.sourceType = command.sourceType; + this.interestingHealthProviderNames = command.interestingHealthProviderNames || []; + this.configArtifacts = []; + } +} + +export const CLOUDRUN_SERVER_GROUP_TRANSFORMER = 'spinnaker.cloudrun.serverGroup.transformer.service'; +module(CLOUDRUN_SERVER_GROUP_TRANSFORMER, []).service( + 'cloudrunV2ServerGroupTransformer', + CloudrunV2ServerGroupTransformer, +); diff --git a/packages/cloudrun/tsconfig.json b/packages/cloudrun/tsconfig.json new file mode 100644 index 00000000000..4d7b8b545e8 --- /dev/null +++ b/packages/cloudrun/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.app.base.json", + "compilerOptions": { + "jsx": "react", + "outDir": "dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["**/*.spec.*"] +} diff --git a/scripts/buildModules.js b/scripts/buildModules.js index bd458574b91..40f627d0557 100755 --- a/scripts/buildModules.js +++ b/scripts/buildModules.js @@ -23,6 +23,7 @@ async function buildModules() { 'appengine', 'azure', 'cloudfoundry', + 'cloudrun', 'docker', 'google', 'huaweicloud', diff --git a/scripts/build_order.sh b/scripts/build_order.sh index 89a69d5f0c2..c59e362b748 100755 --- a/scripts/build_order.sh +++ b/scripts/build_order.sh @@ -11,6 +11,7 @@ ModuleDeps () { appengine) echo "core" ;; azure) echo "core" ;; cloudfoundry) echo "core" ;; + cloudrun) echo "core" ;; core) echo "presentation";; docker) echo "core" ;; ecs) echo "amazon docker core" ;; diff --git a/scripts/bumpPackage.js b/scripts/bumpPackage.js index 1144e0f15e4..95cdd8cfd9b 100755 --- a/scripts/bumpPackage.js +++ b/scripts/bumpPackage.js @@ -22,6 +22,7 @@ const packages = [ 'packages/appengine/', 'packages/azure/', 'packages/cloudfoundry/', + 'packages/cloudrun/', 'packages/core/', 'packages/docker/', 'packages/ecs/', diff --git a/tsconfig.json b/tsconfig.json index b5639f1baad..978da4e0ae8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -57,7 +57,10 @@ "azure": ["azure/src"], "@spinnaker/ecs": ["ecs/src"], "ecs/*": ["ecs/src/*"], - "ecs": ["ecs/src"] + "ecs": ["ecs/src"], + "@spinnaker/cloudrun": ["cloudrun/src"], + "cloudrun/*": ["cloudrun/src/*"], + "cloudrun": ["cloudrun/src"], }, "pretty": true, "removeComments": false,