diff --git a/src/app/backend/apihandler.go b/src/app/backend/apihandler.go index ae1996622ec2c65636b739515307bd2e6f1b6a55..52bcd1d2e914a099e67fabef6ec5dad8c2ade3f0 100644 --- a/src/app/backend/apihandler.go +++ b/src/app/backend/apihandler.go @@ -36,10 +36,16 @@ func CreateHttpApiHandler(client *client.Client) http.Handler { To(apiHandler.handleDeploy). Reads(AppDeploymentSpec{}). Writes(AppDeploymentSpec{})) + deployWs.Route( + deployWs.POST("/validate/name"). + To(apiHandler.handleNameValidity). + Reads(AppNameValiditySpec{}). + Writes(AppNameValidity{})) wsContainer.Add(deployWs) replicaSetWs := new(restful.WebService) replicaSetWs.Path("/api/replicasets"). + Consumes(restful.MIME_JSON). Produces(restful.MIME_JSON) replicaSetWs.Route( replicaSetWs.GET(""). @@ -117,6 +123,23 @@ func (apiHandler *ApiHandler) handleDeploy(request *restful.Request, response *r response.WriteHeaderAndEntity(http.StatusCreated, appDeploymentSpec) } +// Handles app name validation API call. +func (apiHandler *ApiHandler) handleNameValidity(request *restful.Request, response *restful.Response) { + spec := new(AppNameValiditySpec) + if err := request.ReadEntity(spec); err != nil { + handleInternalError(response, err) + return + } + + validity, err := ValidateAppName(spec, apiHandler.client) + if err != nil { + handleInternalError(response, err) + return + } + + response.WriteHeaderAndEntity(http.StatusCreated, validity) +} + // Handles get Replica Set list API call. func (apiHandler *ApiHandler) handleGetReplicaSetList( request *restful.Request, response *restful.Response) { diff --git a/src/app/backend/validateappname.go b/src/app/backend/validateappname.go new file mode 100644 index 0000000000000000000000000000000000000000..2028d3ba7dadf44b10546a14bf3f51c92fd50278 --- /dev/null +++ b/src/app/backend/validateappname.go @@ -0,0 +1,70 @@ +// 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. + +package main + +import ( + k8serrors "k8s.io/kubernetes/pkg/api/errors" + client "k8s.io/kubernetes/pkg/client/unversioned" +) + +// Specification for application name validation request. +type AppNameValiditySpec struct { + // Name of the application. + Name string `json:"name"` + + // Namescpace of the application. + Namespace string `json:"namespace"` +} + +// Describes validity of the application name. +type AppNameValidity struct { + // True when the applcation name is valid. + Valid bool `json:"valid"` +} + +// Validates application name. When error is returned, name validity could not be determined. +func ValidateAppName(spec *AppNameValiditySpec, client client.Interface) (*AppNameValidity, error) { + isValidRc := false + isValidService := false + + _, err := client.ReplicationControllers(spec.Namespace).Get(spec.Name) + if err != nil { + if isNotFoundError(err) { + isValidRc = true + } else { + return nil, err + } + } + + _, err = client.Services(spec.Namespace).Get(spec.Name) + if err != nil { + if isNotFoundError(err) { + isValidService = true + } else { + return nil, err + } + } + + return &AppNameValidity{Valid: isValidRc && isValidService}, nil +} + +// Returns true when the given error is 404-NotFound error. +func isNotFoundError(err error) bool { + statusErr, ok := err.(*k8serrors.StatusError) + if !ok { + return false + } + return statusErr.ErrStatus.Code == 404 +} diff --git a/src/app/externs/backendapi.js b/src/app/externs/backendapi.js index 37715c8977fe98a4f4a7431a78e0eb9c1ed9f139..01822d7904d3c35e4f4771a1714a97c283d19f9e 100644 --- a/src/app/externs/backendapi.js +++ b/src/app/externs/backendapi.js @@ -193,3 +193,18 @@ backendApi.ReplicaSetPods; * }} */ backendApi.Logs; + +/** + * @typedef {{ + * name: string, + * namespace: string + * }} + */ +backendApi.AppNameValiditySpec; + +/** + * @typedef {{ + * valid: boolean + * }} + */ +backendApi.AppNameValidity; diff --git a/src/app/frontend/deploy/deploy_module.js b/src/app/frontend/deploy/deploy_module.js index 1031ccedc65c346515aa3dd160124c930f3c29ca..c3aef82e95f6b599bbe04b72ac0c1c9b33cde99e 100644 --- a/src/app/frontend/deploy/deploy_module.js +++ b/src/app/frontend/deploy/deploy_module.js @@ -18,6 +18,7 @@ import deployLabelDirective from './deploylabel_directive'; import deployFromFileDirective from './deployfromfile_directive'; import fileReaderDirective from './filereader_directive'; import uploadDirective from './upload_directive'; +import uniqueNameDirective from './uniquename_directive'; import helpSectionModule from './helpsection/helpsection_module'; /** @@ -36,6 +37,7 @@ export default angular.module( .config(stateConfig) .directive('deployFromSettings', deployFromSettingsDirective) .directive('deployFromFile', deployFromFileDirective) + .directive('kdUniqueName', uniqueNameDirective) .directive('kdFileReader', fileReaderDirective) .directive('kdUpload', uploadDirective) .directive('kdLabel', deployLabelDirective); diff --git a/src/app/frontend/deploy/deployfromsettings.html b/src/app/frontend/deploy/deployfromsettings.html index 5fb92e73d7c0ed4cc5fc0af7c8a62b9d623d2bb1..d9dd18077532eb7d4bf626485fa69c750b9acd8f 100644 --- a/src/app/frontend/deploy/deployfromsettings.html +++ b/src/app/frontend/deploy/deployfromsettings.html @@ -15,14 +15,19 @@ limitations under the License. --> - + - + + + Application name is required. + + Application with this name already exists within namespace {{ctrl.namespace}}. + - An 'app' label with this value will be added to the Replica Set and Service that get deployed. diff --git a/src/app/frontend/deploy/deployfromsettings.scss b/src/app/frontend/deploy/deployfromsettings.scss new file mode 100644 index 0000000000000000000000000000000000000000..f05a350fb522138a8503d249b7bbb772dfcb8581 --- /dev/null +++ b/src/app/frontend/deploy/deployfromsettings.scss @@ -0,0 +1,25 @@ +// 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. + +md-progress-linear { + &.kd-deploy-form-name-progress { + $bar-height: 2px; + + clear: left; + height: $bar-height; + margin-bottom: -$bar-height; + overflow: hidden; + top: -$bar-height; + } +} diff --git a/src/app/frontend/deploy/deployfromsettings_controller.js b/src/app/frontend/deploy/deployfromsettings_controller.js index 06a60accd6695cdb762b9a1255acc06c38144103..48827e0982aa0260be09a0c97766e70b59284065 100644 --- a/src/app/frontend/deploy/deployfromsettings_controller.js +++ b/src/app/frontend/deploy/deployfromsettings_controller.js @@ -15,6 +15,7 @@ import showNamespaceDialog from './createnamespace_dialog'; import DeployLabel from './deploylabel'; import {stateName as replicasetliststate} from 'replicasetlist/replicasetlist_state'; +import {uniqueNameValidationKey} from './uniquename_directive'; // Label keys for predefined labels const APP_LABEL_KEY = 'app'; @@ -194,6 +195,20 @@ export default class DeployFromSettingsController { () => { this.namespace = this.namespaces[0]; }); } + /** + * Returns true when name input should show error. This overrides default behavior to show name + * uniqueness errors even in the middle of typing. + * @return {boolean} + * @export + */ + isNameError() { + /** @type {!angular.NgModelController} */ + let name = this.form['name']; + + return name.$error[uniqueNameValidationKey] || + (name.$invalid && (name.$touched || this.form.$submitted)); + } + /** * Converts array of DeployLabel to array of backend api label * @param {!Array} labels diff --git a/src/app/frontend/deploy/uniquename_directive.js b/src/app/frontend/deploy/uniquename_directive.js new file mode 100644 index 0000000000000000000000000000000000000000..7109a6c6b11a05fc236f28f45fc0a44369eaa483 --- /dev/null +++ b/src/app/frontend/deploy/uniquename_directive.js @@ -0,0 +1,74 @@ +// 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. + +/** The name of this directive. */ +export const uniqueNameValidationKey = 'uniqueName'; + +/** + * Validates that application name is unique within the given namespace. + * + * @param {!angular.$resource} $resource + * @param {!angular.$q} $q + * @return {!angular.Directive} + * @ngInject + */ +export default function uniqueNameDirective($resource, $q) { + const namespaceParam = 'namespace'; + + return { + restrict: 'A', + require: 'ngModel', + scope: { + [namespaceParam]: '=', + }, + link: function(scope, element, attrs, ctrl) { + /** @type {!angular.NgModelController} */ + let ngModelController = ctrl; + + scope.$watch(namespaceParam, () => { ctrl.$validate(); }); + ngModelController.$asyncValidators[uniqueNameValidationKey] = + (name) => { return validate(name, scope[namespaceParam], $resource, $q); }; + }, + }; +} + +/** + * @param {string} name + * @param {string} namespace + * @param {!angular.$resource} resource + * @param {!angular.$q} q + */ +function validate(name, namespace, resource, q) { + let deferred = q.defer(); + + /** @type {!angular.Resource} */ + let resourceClass = resource('/api/appdeployments/validate/name'); + /** @type {!backendApi.AppNameValiditySpec} */ + let spec = {name: name, namespace: namespace}; + resourceClass.save( + spec, + /** + * @param {!backendApi.AppNameValidity} validity + */ + (validity) => { + if (validity.valid === true) { + deferred.resolve(); + } else { + deferred.reject(); + } + }, + () => { deferred.reject(); }); + + return deferred.promise; +} diff --git a/src/test/backend/validateappname_test.go b/src/test/backend/validateappname_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3213b2f6c5e05e0c8a9b2b6d4ea2f1a6b711cb39 --- /dev/null +++ b/src/test/backend/validateappname_test.go @@ -0,0 +1,66 @@ +// 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. + +package main + +import ( + "fmt" + "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/client/unversioned/testclient" + "k8s.io/kubernetes/pkg/runtime" + "testing" +) + +func TestValidateName(t *testing.T) { + spec := &AppNameValiditySpec{ + Namespace: "foo-namespace", + Name: "foo-name", + } + cases := []struct { + spec *AppNameValiditySpec + objects []runtime.Object + expected bool + }{ + { + spec, + nil, + true, + }, + { + spec, + []runtime.Object{&api.ReplicationController{}}, + false, + }, + { + spec, + []runtime.Object{&api.Service{}}, + false, + }, + { + spec, + []runtime.Object{&api.ReplicationController{}, &api.Service{}}, + false, + }, + } + + for _, c := range cases { + testClient := testclient.NewSimpleFake(c.objects...) + validity, _ := ValidateAppName(c.spec, testClient) + fmt.Printf("%#v\n", validity) + if validity.Valid != c.expected { + t.Errorf("Expected %#v validity to be %#v for objects %#v, but was %#v\n", + c.spec, c.expected, c.objects, validity) + } + } +} diff --git a/src/test/frontend/deploy/deployfromsettings_controller_test.js b/src/test/frontend/deploy/deployfromsettings_controller_test.js index 6f937fdace302a38400e745eb7bf4e68751ef4bd..bfe03b49ae09e550bdb6b2e1f20bc3f8731bf7f5 100644 --- a/src/test/frontend/deploy/deployfromsettings_controller_test.js +++ b/src/test/frontend/deploy/deployfromsettings_controller_test.js @@ -15,19 +15,33 @@ import DeployFromSettingController from 'deploy/deployfromsettings_controller'; import deployModule from 'deploy/deploy_module'; import DeployLabel from 'deploy/deploylabel'; +import {uniqueNameValidationKey} from 'deploy/uniquename_directive'; describe('DeployFromSettings controller', () => { /** @type {!DeployFromSettingController} */ let ctrl; /** @type {!angular.$resource} */ let mockResource; + /** @type {!angular.FormController} */ + let form; beforeEach(() => { angular.mock.module(deployModule.name); angular.mock.inject(($controller) => { mockResource = jasmine.createSpy('$resource'); - ctrl = $controller(DeployFromSettingController, {$resource: mockResource}, {namespaces: []}); + form = { + $submitted: false, + name: { + $touched: false, + $invalid: false, + $error: { + [uniqueNameValidationKey]: false, + }, + }, + }; + ctrl = $controller( + DeployFromSettingController, {$resource: mockResource}, {namespaces: [], form: form}); }); }); @@ -161,7 +175,7 @@ describe('DeployFromSettings controller', () => { it('should hide more options by default', () => { // this is default behavior so no given/when // then - expect(ctrl.isMoreOptionsEnabled()).toBeFalsy(); + expect(ctrl.isMoreOptionsEnabled()).toBe(false); }); it('should show more options after switch', () => { @@ -169,6 +183,44 @@ describe('DeployFromSettings controller', () => { ctrl.switchMoreOptions(); // then - expect(ctrl.isMoreOptionsEnabled()).toBeTruthy(); + expect(ctrl.isMoreOptionsEnabled()).toBe(true); + }); + + describe('isNameError', () => { + it('should show all errors on submit', () => { + expect(ctrl.isNameError()).toBe(false); + + form.name.$invalid = true; + + expect(ctrl.isNameError()).toBe(false); + + form.$submitted = true; + + expect(ctrl.isNameError()).toBe(true); + }); + + it('should show all errors when touched', () => { + expect(ctrl.isNameError()).toBe(false); + + form.name.$invalid = true; + + expect(ctrl.isNameError()).toBe(false); + + form.name.$touched = true; + + expect(ctrl.isNameError()).toBe(true); + }); + + it('should always show name uniqueness errors', () => { + expect(ctrl.isNameError()).toBe(false); + + form.name.$error[uniqueNameValidationKey] = true; + + expect(ctrl.isNameError()).toBe(true); + + form.$submitted = true; + + expect(ctrl.isNameError()).toBe(true); + }); }); }); diff --git a/src/test/frontend/deploy/uniquename_directive_test.js b/src/test/frontend/deploy/uniquename_directive_test.js new file mode 100644 index 0000000000000000000000000000000000000000..8748ac546dd2fc657694e4523b3f71099d828824 --- /dev/null +++ b/src/test/frontend/deploy/uniquename_directive_test.js @@ -0,0 +1,91 @@ +// 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 deployModule from 'deploy/deploy_module'; + +describe('DeployFromSettings controller', () => { + /** @type {function(!angular.Scope):!angular.JQLite} */ + let compileFn; + /** @type {!angular.Scope} */ + let scope; + /** @type {!angular.$httpBackend} */ + let httpBackend; + + beforeEach(() => { + angular.mock.module(deployModule.name); + + angular.mock.inject(($compile, $rootScope, $httpBackend) => { + compileFn = $compile('
'); + scope = $rootScope.$new(); + httpBackend = $httpBackend; + }); + }); + + it('should validate name asynchronosuly', () => { + scope.name = 'foo-name'; + scope.namespace = 'foo-namespace'; + let endpoint = httpBackend.when('POST', '/api/appdeployments/validate/name'); + + let elem = compileFn(scope)[0]; + expect(elem.classList).toContain('ng-valid'); + expect(elem.classList).not.toContain('ng-pending'); + + endpoint.respond({ + valid: false, + }); + scope.$apply(); + expect(elem.classList).not.toContain('ng-valid'); + expect(elem.classList).toContain('ng-pending'); + + httpBackend.flush(); + expect(elem.classList).toContain('ng-invalid'); + expect(elem.classList).not.toContain('ng-valid'); + expect(elem.classList).not.toContain('ng-pending'); + + scope.name = 'foo-name2'; + endpoint.respond({ + valid: true, + }); + scope.$apply(); + httpBackend.flush(); + expect(elem.classList).toContain('ng-valid'); + }); + + it('should validate on namespace change', () => { + scope.name = 'foo-name'; + scope.namespace = 'foo-namespace'; + + let elem = compileFn(scope)[0]; + httpBackend.when('POST', '/api/appdeployments/validate/name').respond({ + valid: false, + }); + httpBackend.flush(); + expect(elem.classList).not.toContain('ng-pending'); + + scope.namespace = 'foo-namespace2'; + scope.$apply(); + expect(elem.classList).toContain('ng-pending'); + }); + + it('should treat failures as invalid name', () => { + scope.name = 'foo-name'; + scope.namespace = 'foo-namespace'; + + let elem = compileFn(scope)[0]; + httpBackend.when('POST', '/api/appdeployments/validate/name').respond(503, ''); + httpBackend.flush(); + expect(elem.classList).not.toContain('ng-pending'); + expect(elem.classList).toContain('ng-invalid'); + }); +});