diff --git a/src/app/backend/deploy.go b/src/app/backend/deploy.go index abb62760b0613691c931061b1dfa92be21f49d97..1eae9b95687fbc013cd51832b721f4faf1f2e1e5 100644 --- a/src/app/backend/deploy.go +++ b/src/app/backend/deploy.go @@ -22,7 +22,6 @@ import ( const ( DescriptionAnnotationKey = "description" - NameLabelKey = "name" ) // Specification for an app deployment. @@ -48,6 +47,9 @@ type AppDeploymentSpec struct { // Target namespace of the application. Namespace string `json:"namespace"` + + // Labels that will be defined on Pods/RCs/Services + Labels []Label `json:"labels"` } // Port mapping for an application deployment. @@ -62,13 +64,22 @@ type PortMapping struct { Protocol api.Protocol `json:"protocol"` } +// Structure representing label assignable to Pod/RC/Service +type Label struct { + // Label key + Key string `json:"key"` + + // Label value + Value string `json:"value"` +} + // Deploys an app based on the given configuration. The app is deployed using the given client. // App deployment consists of a replication controller and an optional service. Both of them share // common labels. // TODO(bryk): Write tests for this function. func DeployApp(spec *AppDeploymentSpec, client *client.Client) error { annotations := map[string]string{DescriptionAnnotationKey: spec.Description} - labels := map[string]string{NameLabelKey: spec.Name} + labels := getLabelsMap(spec.Labels) objectMeta := api.ObjectMeta{ Annotations: annotations, Name: spec.Name, @@ -137,3 +148,14 @@ func DeployApp(spec *AppDeploymentSpec, client *client.Client) error { return nil } } + +// Converts array of labels to map[string]string +func getLabelsMap(labels []Label) map[string]string { + result := make(map[string]string) + + for _, label := range labels { + result[label.Key] = label.Value + } + + return result +} diff --git a/src/app/externs/backendapi.js b/src/app/externs/backendapi.js index 7bdd90fc525e0743d7a0da6a6febb2b21d61f4d9..b2bd8ef33dababbddf78e0fa04d4525f7cdd26cd 100644 --- a/src/app/externs/backendapi.js +++ b/src/app/externs/backendapi.js @@ -41,7 +41,8 @@ backendApi.PortMapping; * description: string, * portMappings: !Array, * replicas: number, - * namespace: string + * namespace: string, + * labels: !Array * }} */ backendApi.AppDeploymentSpec; @@ -106,3 +107,11 @@ backendApi.NamespaceSpec; * }} */ backendApi.NamespaceList; + +/** + * @typedef {{ + * key: string, + * value: string + * }} + */ +backendApi.Label; diff --git a/src/app/frontend/.eslintrc b/src/app/frontend/.eslintrc index 02d6b040627caeef9f91cdcd031a0831dd7d9878..28869c4f3fce2f976c8edcb27d7e08936e7df7f2 100644 --- a/src/app/frontend/.eslintrc +++ b/src/app/frontend/.eslintrc @@ -10,5 +10,8 @@ "angular/module-getter": 0, // Disable undefined variable checking. This is done by the compiler. "no-undef": 0, + // To allow comparison to undefined variable, because Google Closure Compiler does not + // understand angular.isDefined correctly. + "angular/definedundefined": 0, } } diff --git a/src/app/frontend/deploy/deploy.html b/src/app/frontend/deploy/deploy.html index 06df4579a1fe03160fe75615bcf8f9a1a9327bda..0af64038e446f6dbe664f5c798ca1c5283012539 100644 --- a/src/app/frontend/deploy/deploy.html +++ b/src/app/frontend/deploy/deploy.html @@ -17,7 +17,7 @@ limitations under the License.

Deploy a Containerized App

-
+ diff --git a/src/app/frontend/deploy/deploy_controller.js b/src/app/frontend/deploy/deploy_controller.js index 0ea19f56d7588778539675b2a4863e109ec618e1..41c5b85235b4e420bcb7ce60832b2c0c3699bea9 100644 --- a/src/app/frontend/deploy/deploy_controller.js +++ b/src/app/frontend/deploy/deploy_controller.js @@ -30,6 +30,9 @@ export default class DeployController { * @ngInject */ constructor($resource, $log, $state, $mdDialog, namespaces) { + /** @export {!angular.FormController} Initialized from the template */ + this.deployForm; + /** @export {string} */ this.name = ''; @@ -108,7 +111,7 @@ export default class DeployController { * @return {boolean} * @export */ - isDeployDisabled() { return this.isDeployInProgress_; } + isDeployDisabled() { return this.isDeployInProgress_ || this.deployForm.$invalid; } /** * Cancels the deployment form. diff --git a/src/app/frontend/deploy/deploy_module.js b/src/app/frontend/deploy/deploy_module.js index 642b5d9ffd5b07aa100fba76b859a546d67f9ff9..eb47072d34f37a56b7b83aa01fe8a8234e169fd8 100644 --- a/src/app/frontend/deploy/deploy_module.js +++ b/src/app/frontend/deploy/deploy_module.js @@ -14,6 +14,7 @@ import stateConfig from './deploy_state'; import deployFromSettingsDirective from './deployfromsettings_directive'; +import deployLabelDirective from './deploylabel_directive'; /** * Angular module for the deploy view. @@ -24,7 +25,9 @@ export default angular.module( 'kubernetesDashboard.deploy', [ 'ngMaterial', + 'ngResource', 'ui.router', ]) .config(stateConfig) - .directive('deployFromSettings', deployFromSettingsDirective); + .directive('deployFromSettings', deployFromSettingsDirective) + .directive('kdLabel', deployLabelDirective); diff --git a/src/app/frontend/deploy/deployfromsettings.html b/src/app/frontend/deploy/deployfromsettings.html index 8c44b8d328a1839185333b898f78af07357ce61c..4fce71ddf5400a222d0301f3a30beb6f8d567dbb 100644 --- a/src/app/frontend/deploy/deployfromsettings.html +++ b/src/app/frontend/deploy/deployfromsettings.html @@ -47,3 +47,15 @@ limitations under the License. +
+
Labels (optional)
+
+
+

Key

+

Value

+
+
+ +
+
+
diff --git a/src/app/frontend/deploy/deployfromsettings_controller.js b/src/app/frontend/deploy/deployfromsettings_controller.js index 9ea169dea8ac827a612820500480eb531cb222d4..a87a5683c549a9dfe44ff07c7f88664b0e2a7355 100644 --- a/src/app/frontend/deploy/deployfromsettings_controller.js +++ b/src/app/frontend/deploy/deployfromsettings_controller.js @@ -12,8 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +import DeployLabel from './deploylabel'; import {stateName as replicasetliststate} from 'replicasetlist/replicasetlist_state'; +// Label keys for predefined labels +export const APP_LABEL_KEY = 'app'; +export const VERSION_LABEL_KEY = 'version'; + /** * Controller for the deploy from settings directive. * @@ -68,6 +73,13 @@ export default class DeployFromSettingsController { /** @export {boolean} */ this.isExternal = false; + + /** @export {!Array} */ + this.labels = [ + new DeployLabel(APP_LABEL_KEY, '', false, this.getName_.bind(this)), + new DeployLabel(VERSION_LABEL_KEY, '', false, this.getContainerImageVersion_.bind(this)), + new DeployLabel(), + ]; } /** @@ -87,6 +99,7 @@ export default class DeployFromSettingsController { portMappings: this.portMappings.filter(this.isPortMappingEmpty_), replicas: this.replicas, namespace: this.namespace, + labels: this.toBackendApiLabels_(this.labels), }; let defer = this.q_.defer(); @@ -107,6 +120,22 @@ export default class DeployFromSettingsController { return defer.promise; } + /** + * Converts array of DeployLabel to array of backend api label + * @param {!Array} labels + * @return {!Array} + * @private + */ + toBackendApiLabels_(labels) { + // Omit labels with empty key/value + /** @type {!Array} */ + let apiLabels = + labels.filter((label) => { return label.key.length !== 0 && label.value().length !== 0; }); + + // Transform to array of backend api labels + return apiLabels.map((label) => { return label.toBackendApi(); }); + } + /** * @param {string} defaultProtocol * @return {!backendApi.PortMapping} @@ -123,4 +152,31 @@ export default class DeployFromSettingsController { * @private */ isPortMappingEmpty_(portMapping) { return !!portMapping.port && !!portMapping.targetPort; } + + /** + * Callbacks used in DeployLabel model to make it aware of controller state changes. + */ + + /** + * Returns extracted from link container image version. + * @return {string} + * @private + */ + getContainerImageVersion_() { + /** @type {number} */ + let index = (this.containerImage || '').lastIndexOf(':'); + + if (index > -1) { + return this.containerImage.substring(index + 1); + } + + return ''; + } + + /** + * Returns application name. + * @return {string} + * @private + */ + getName_() { return this.name; } } diff --git a/src/app/frontend/deploy/deploylabel.html b/src/app/frontend/deploy/deploylabel.html new file mode 100644 index 0000000000000000000000000000000000000000..8fdb800450d3a99aec21b4bddf894ec651ef1a0c --- /dev/null +++ b/src/app/frontend/deploy/deploylabel.html @@ -0,0 +1,36 @@ + + + + + + + {{labelCtrl.label.key}} is not unique. + + +

+ + + + + delete + +
diff --git a/src/app/frontend/deploy/deploylabel.js b/src/app/frontend/deploy/deploylabel.js new file mode 100644 index 0000000000000000000000000000000000000000..009baf0e2f54f9e1dd24b5597a842dc689481403 --- /dev/null +++ b/src/app/frontend/deploy/deploylabel.js @@ -0,0 +1,69 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Represents label object used in deploy form view. + * @final + */ +export default class DeployLabel { + /** + * Constructs DeployLabel object. + * @param {string} key + * @param {string} value + * @param {boolean} editable + * @param {function(string): string} derivedValueGetterFn - callback + */ + constructor(key = '', value = '', editable = true, derivedValueGetterFn = undefined) { + /** @export {boolean} */ + this.editable = editable; + + /** @export {string} */ + this.key = key; + + /** @private {string} */ + this.value_ = value; + + /** @private {function(string): string|undefined} */ + this.derivedValueGetter_ = derivedValueGetterFn; + } + + /** + * @param {string} [newValue] + * @return {string} + * @export + */ + value(newValue) { + if (this.derivedValueGetter_ !== undefined) { + if (newValue !== undefined) { + throw Error("Can not set value of derived label."); + } + + return this.derivedValueGetter_(this.key); + } + + return newValue !== undefined ? (this.value_ = newValue) : this.value_; + } + + /** + * Converts 'this' object to backendApi.Label object. + * @return {!backendApi.Label} + * @export + */ + toBackendApi() { + return { + key: this.key, + value: this.value(), + }; + } +} diff --git a/src/app/frontend/deploy/deploylabel.scss b/src/app/frontend/deploy/deploylabel.scss new file mode 100644 index 0000000000000000000000000000000000000000..9db34e731a1baae33fb2d133435fa68654d7987e --- /dev/null +++ b/src/app/frontend/deploy/deploylabel.scss @@ -0,0 +1,20 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +.kd-deploy-labels-button { + min-width: 10px; + margin: 0; + padding: 0; + height: 10px; +} diff --git a/src/app/frontend/deploy/deploylabel_controller.js b/src/app/frontend/deploy/deploylabel_controller.js new file mode 100644 index 0000000000000000000000000000000000000000..2a2b3f7cc325ec65f97b38687a08228a30cd5b9c --- /dev/null +++ b/src/app/frontend/deploy/deploylabel_controller.js @@ -0,0 +1,158 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import DeployLabel from './deploylabel'; + +/** + * Service used for handling label actions like: hover, showing duplicated key error, etc. + * @final + */ +export default class DeployLabelController { + /** + * Constructs our label controller. + */ + constructor() { + /** @export {!DeployLabel} Initialized from the scope. */ + this.label; + + /** @export {!Array} Initialized from the scope. */ + this.labels; + } + + /** + * Calls checks on label: + * - adds label if last empty label has been filled + * - removes label if some label in the middle has key and value empty + * - checks for duplicated key and sets validity of element + * @param {!angular.FormController|undefined} labelForm + * @export + */ + check(labelForm) { + this.addIfNeeded_(); + this.removeIfNeeded_(); + this.validateKey_(labelForm); + } + + /** + * Returns true when label is editable and is not last on the list. + * Used to indicate whether delete icon should be shown near label. + * @return {boolean} + * @export + */ + isRemovable() { + /** @type {!DeployLabel} */ + let lastElement = this.labels[this.labels.length - 1]; + return !!(this.label.editable && this.label !== lastElement); + } + + /** + * Deletes row from labels list. + * @export + */ + deleteLabel() { + /** @type {number} */ + let rowIdx = this.labels.indexOf(this.label); + + if (rowIdx > -1) { + this.labels.splice(rowIdx, 1); + } + } + + /** + * Adds label if last label key and value has been filled. + * @private + */ + addIfNeeded_() { + /** @type {!DeployLabel} */ + let lastLabel = this.labels[this.labels.length - 1]; + + if (this.isFilled_(lastLabel)) { + this.addNewLabel_(); + } + } + + /** + * Adds row to labels list. + * @private + */ + addNewLabel_() { this.labels.push(new DeployLabel()); } + + /** + * Removes label from labels list if label is empty and is not last label. + * @private + */ + removeIfNeeded_() { + /** @type {!DeployLabel} */ + let lastLabel = this.labels[this.labels.length - 1]; + + if (this.isEmpty_(this.label) && this.label !== lastLabel) { + this.deleteLabel(); + } + } + + /** + * Validates label withing label form. + * Current checks: + * - duplicated key + * @param {!angular.FormController|undefined} labelForm + * @private + */ + validateKey_(labelForm) { + if (angular.isDefined(labelForm)) { + /** @type {!angular.NgModelController} */ + let elem = labelForm.key; + + // TODO(floreks): Validate label key/value. + /** @type {boolean} */ + let isValid = !this.isDuplicated_(); + + elem.$setValidity('unique', isValid); + } + } + + /** + * Returns true if there are 2 or more labels with the same key on the labelList, + * false otherwise. + * @return {boolean} + * @private + */ + isDuplicated_() { + /** @type {number} */ + let duplications = 0; + + this.labels.forEach((label) => { + if (this.label.key.length !== 0 && label.key === this.label.key) { + duplications++; + } + }); + + return duplications > 1; + } + + /** + * Returns true if label key and value are empty, false otherwise. + * @param {!DeployLabel} label + * @return {boolean} + * @private + */ + isEmpty_(label) { return label.key.length === 0 && label.value().length === 0; } + + /** + * Returns true if label key and value are not empty, false otherwise. + * @param {!DeployLabel} label + * @return {boolean} + * @private + */ + isFilled_(label) { return label.key.length !== 0 && label.value().length !== 0; } +} diff --git a/src/app/frontend/deploy/deploylabel_directive.js b/src/app/frontend/deploy/deploylabel_directive.js new file mode 100644 index 0000000000000000000000000000000000000000..b2c0288a331aef12e53a485da983374c3ba43ce6 --- /dev/null +++ b/src/app/frontend/deploy/deploylabel_directive.js @@ -0,0 +1,33 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import DeployLabelController from './deploylabel_controller'; + +/** + * Returns directive definition for deploy form label. + * + * @return {!angular.Directive} + */ +export default function labelDirective() { + return { + controller: DeployLabelController, + controllerAs: 'labelCtrl', + templateUrl: 'deploy/deploylabel.html', + scope: {}, + bindToController: { + label: '=', + labels: '=', + }, + }; +} diff --git a/src/test/frontend/.eslintrc b/src/test/frontend/.eslintrc index 69bcf545710fe598e9084b46508bae8ccb97d4bf..25de081e201506e1d1f9873d256a50efe0caed3d 100644 --- a/src/test/frontend/.eslintrc +++ b/src/test/frontend/.eslintrc @@ -4,7 +4,6 @@ }, // Define missing global Angular testing environment variables. "globals": { - "inject": true, - "module": true, + "angular": true, }, } diff --git a/src/test/frontend/deploy/deployfromsettings_controller_test.js b/src/test/frontend/deploy/deployfromsettings_controller_test.js new file mode 100644 index 0000000000000000000000000000000000000000..6be7ea038379d7840bd233237c4281198553aa51 --- /dev/null +++ b/src/test/frontend/deploy/deployfromsettings_controller_test.js @@ -0,0 +1,51 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import DeployFromSettingController from 'deploy/deployfromsettings_controller'; +import deployModule from 'deploy/deploy_module'; + +describe('DeployFromSettings controller', () => { + let ctrl; + + beforeEach(() => { + angular.mock.module(deployModule.name); + + angular.mock.inject(($log, $state, $resource, $q) => { + ctrl = new DeployFromSettingController($log, $state, $resource, $q); + }); + }); + + it('should return empty string when containerImage is undefined', () => { + ctrl.containerImage = undefined; + expect(ctrl.getContainerImageVersion_()).toEqual(''); + }); + + it('should return empty string when containerImage is empty', () => { + ctrl.containerImage = ''; + expect(ctrl.getContainerImageVersion_()).toEqual(''); + }); + + it('should return empty string when containerImage is not empty and does not contain `:`' + + ' delimiter', + () => { + ctrl.containerImage = 'test'; + expect(ctrl.getContainerImageVersion_()).toEqual(''); + }); + + it('should return part of the string after `:` delimiter', () => { + ctrl.containerImage = 'test:1'; + expect(ctrl.getContainerImageVersion_()).toEqual('1'); + }); + +}); diff --git a/src/test/frontend/zerostate/zerostate_controller_test.js b/src/test/frontend/zerostate/zerostate_controller_test.js index caccd083ff2c3f34cd6a332c3ec285e37daaa61a..d03714c8e524fb225fd42ad355b62933bd5f8182 100644 --- a/src/test/frontend/zerostate/zerostate_controller_test.js +++ b/src/test/frontend/zerostate/zerostate_controller_test.js @@ -15,12 +15,12 @@ import ZerostateController from 'zerostate/zerostate_controller'; describe('Main controller', () => { - let vm; + let ctrl; - beforeEach(inject(($timeout) => { vm = new ZerostateController($timeout); })); + beforeEach(angular.mock.inject(() => { ctrl = new ZerostateController(); })); it('should do something', () => { - expect(vm.learnMoreLinks).toEqual([ + expect(ctrl.learnMoreLinks).toEqual([ {title: 'Dashboard Tour', link: "#"}, {title: 'Deploying your App', link: "#"}, {title: 'Monitoring your App', link: "#"}, diff --git a/src/test/integration/deploy/deploy_test.js b/src/test/integration/deploy/deploy_test.js new file mode 100644 index 0000000000000000000000000000000000000000..fc127ac4d5abc7863efc10d2ba22bac66fadf45d --- /dev/null +++ b/src/test/integration/deploy/deploy_test.js @@ -0,0 +1,27 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +describe('Deploy view', function() { + beforeEach(function() { browser.get('#/deploy'); }); + + it('should not contain errors in console', function() { + browser.manage().logs().get('browser').then(function(browserLog) { + // Filter and search for errors logs + let filteredLogs = browserLog.filter((log) => { return log.level.value > 900; }); + + // Expect no error logs + expect(filteredLogs.length).toBe(0); + }); + }); +}); diff --git a/src/test/integration/zerostate_po.js b/src/test/integration/zerostate/zerostate_po.js similarity index 100% rename from src/test/integration/zerostate_po.js rename to src/test/integration/zerostate/zerostate_po.js diff --git a/src/test/integration/zerostate_test.js b/src/test/integration/zerostate/zerostate_test.js similarity index 100% rename from src/test/integration/zerostate_test.js rename to src/test/integration/zerostate/zerostate_test.js