diff --git a/karma.conf.js b/karma.conf.js index 38e8ca8fa..220ed46e0 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -24,11 +24,14 @@ module.exports = function(config) { 'node_modules/raven-js/dist/raven.js', 'node_modules/cal-heatmap/cal-heatmap.js', + // Polyfills + 'node_modules/core-js/index.js', + // static/lib resources 'static/lib/**/*.js', - // Application resources - 'static/js/**/*.spec.ts*', + // Single entrypoint for all tests + 'static/test/test-index.ts', // Tests utils 'static/test/**/*.js', @@ -37,7 +40,8 @@ module.exports = function(config) { preprocessors: { 'static/lib/ngReact/react.ngReact.min.js': ['webpack'], 'static/lib/angular-moment.min.js': ['webpack'], - 'static/js/**/*.ts*': ['webpack'], + 'node_modules/core-js/index.js': ['webpack'], + 'static/test/test-index.ts': ['webpack'], }, webpack: webpackConfig, webpackMiddleware: { diff --git a/package.json b/package.json index 3f8695b11..8d21d6401 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "test": "./node_modules/.bin/karma start --single-run --browsers PhantomJS", "test:node": "JASMINE_CONFIG_PATH=static/test/jasmine.json ./node_modules/.bin/jasmine-ts './static/js/**/*.spec.ts'", - "build": "./node_modules/.bin/webpack --progress -p -v", + "build": "./node_modules/.bin/webpack --progress", "watch": "./node_modules/.bin/webpack --watch" }, "repository": { @@ -24,6 +24,7 @@ "bootstrap": "^3.3.2", "bootstrap-datepicker": "^1.6.4", "cal-heatmap": "^3.3.10", + "core-js": "^2.4.1", "d3": "^3.3.3", "eonasdan-bootstrap-datetimepicker": "^4.17.43", "jquery": "1.12.4", @@ -37,6 +38,7 @@ "@types/angular": "1.6.2", "@types/angular-mocks": "^1.5.8", "@types/angular-route": "^1.3.3", + "@types/angular-sanitize": "^1.3.4", "@types/es6-shim": "^0.31.32", "@types/jasmine": "^2.5.41", "@types/jquery": "^2.0.40", @@ -60,6 +62,7 @@ "source-map-loader": "0.1.5", "style-loader": "0.13.1", "ts-loader": "^0.9.5", + "ts-mocks": "^0.2.2", "typescript": "^2.2.1", "typings": "1.4.0", "webpack": "^2.2" diff --git a/static/js/directives/ui/dockerfile-build-form.js b/static/js/directives/ui/dockerfile-build-form.js index fdd705f21..2a6ef94de 100644 --- a/static/js/directives/ui/dockerfile-build-form.js +++ b/static/js/directives/ui/dockerfile-build-form.js @@ -10,10 +10,8 @@ angular.module('quay').directive('dockerfileBuildForm', function () { restrict: 'C', scope: { 'repository': '=repository', - 'isReady': '=?isReady', 'reset': '=?reset', - 'readyForBuild': '&readyForBuild' }, controller: function($scope, $element, ApiService, DockerfileService, Config) { @@ -39,25 +37,27 @@ angular.module('quay').directive('dockerfileBuildForm', function () { $scope.state = 'checking'; $scope.selectedFiles = files; - DockerfileService.getDockerfile(files[0], function(df) { - var baseImage = df.getRegistryBaseImage(); - if (baseImage) { - checkPrivateImage(baseImage); - } else { - $scope.state = 'ready'; - } + DockerfileService.getDockerfile(files[0]) + .then(function(dockerfileInfo) { + var baseImage = dockerfileInfo.getRegistryBaseImage(); + if (baseImage) { + checkPrivateImage(baseImage); + } else { + $scope.state = 'ready'; + } - $scope.$apply(function() { - opt_callback && opt_callback(true, 'Dockerfile found and valid') - }); - }, function(msg) { - $scope.state = 'empty'; - $scope.privateBaseRepository = null; + $scope.$apply(function() { + opt_callback && opt_callback(true, 'Dockerfile found and valid') + }); + }) + .catch(function(error) { + $scope.state = 'empty'; + $scope.privateBaseRepository = null; - $scope.$apply(function() { - opt_callback && opt_callback(false, msg || 'Could not find valid Dockerfile'); + $scope.$apply(function() { + opt_callback && opt_callback(false, error || 'Could not find valid Dockerfile'); + }); }); - }); }; $scope.handleFilesCleared = function() { diff --git a/static/js/quay.module.ts b/static/js/quay.module.ts index 7cbf2ba63..e1162bb32 100644 --- a/static/js/quay.module.ts +++ b/static/js/quay.module.ts @@ -1,4 +1,5 @@ import * as angular from "angular"; +import 'core-js'; import { ViewArrayImpl } from "./services/view-array/view-array.impl"; import { NAME_PATTERNS } from "./constants/name-patterns.constant"; import { INJECTED_CONFIG, INJECTED_FEATURES, INJECTED_ENDPOINTS } from "./constants/injected-values.constant"; @@ -12,6 +13,10 @@ import { LinearWorkflowComponent } from './directives/ui/linear-workflow/linear- import { LinearWorkflowSectionComponent } from './directives/ui/linear-workflow/linear-workflow-section.component'; import { QuayConfig } from './quay-config.module'; import { QuayRun } from './quay-run.module'; +import { BuildServiceImpl } from './services/build/build.service.impl'; +import { AvatarServiceImpl } from './services/avatar/avatar.service.impl'; +import { DockerfileServiceImpl } from './services/dockerfile/dockerfile.service.impl'; +import { DataFileServiceImpl } from './services/datafile/datafile.service.impl'; /** @@ -33,6 +38,10 @@ import { QuayRun } from './quay-run.module'; ], providers: [ ViewArrayImpl, + BuildServiceImpl, + AvatarServiceImpl, + DockerfileServiceImpl, + DataFileServiceImpl, ], }) export class quay { @@ -42,6 +51,7 @@ export class quay { // TODO: Make injected values into services and move to NgModule.providers, as constants are not supported in Angular 2 angular .module(quay.name) + .factory("fileReaderFactory", () => () => new FileReader()) .constant('NAME_PATTERNS', NAME_PATTERNS) .constant('INJECTED_CONFIG', INJECTED_CONFIG) .constant('INJECTED_FEATURES', INJECTED_FEATURES) diff --git a/static/js/services/avatar-service.js b/static/js/services/avatar-service.js deleted file mode 100644 index 0b1503bfe..000000000 --- a/static/js/services/avatar-service.js +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Service which provides helper methods for retrieving the avatars displayed in the app. - */ -angular.module('quay').factory('AvatarService', ['Config', '$sanitize', 'md5', - function(Config, $sanitize, md5) { - var avatarService = {}; - var cache = {}; - - avatarService.getAvatar = function(hash, opt_size, opt_notfound) { - var size = opt_size || 16; - switch (Config['AVATAR_KIND']) { - case 'local': - return '/avatar/' + hash + '?size=' + size; - break; - - case 'gravatar': - var notfound = opt_notfound || '404'; - return '//www.gravatar.com/avatar/' + hash + '?d=' + notfound + '&size=' + size; - break; - } - }; - - avatarService.computeHash = function(opt_email, opt_name) { - var email = opt_email || ''; - var name = opt_name || ''; - - var cacheKey = email + ':' + name; - if (!cacheKey) { return '-'; } - - if (cache[cacheKey]) { - return cache[cacheKey]; - } - - var hash = md5.createHash(email.toString().toLowerCase()); - switch (Config['AVATAR_KIND']) { - case 'local': - if (name) { - hash = name[0] + hash; - } else if (email) { - hash = email[0] + hash; - } - break; - } - - return cache[cacheKey] = hash; - }; - - return avatarService; -}]); \ No newline at end of file diff --git a/static/js/services/avatar/avatar.service.impl.spec.ts b/static/js/services/avatar/avatar.service.impl.spec.ts new file mode 100644 index 000000000..060fa8b7d --- /dev/null +++ b/static/js/services/avatar/avatar.service.impl.spec.ts @@ -0,0 +1,97 @@ +import { AvatarServiceImpl } from './avatar.service.impl'; + + +describe("AvatarServiceImpl", () => { + var avatarServiceImpl: AvatarServiceImpl; + var configMock: any; + var md5Mock: any; + + beforeEach(() => { + configMock = {AVATAR_KIND: 'local'}; + md5Mock = jasmine.createSpyObj('md5Mock', ['createHash']); + avatarServiceImpl = new AvatarServiceImpl(configMock, md5Mock); + }); + + describe("getAvatar", () => { + var hash: string; + + beforeEach(() => { + hash = "a1b2c3d4e5f6"; + }); + + it("returns a local avatar URL if given config has avatar kind set to local", () => { + var avatarURL: string = avatarServiceImpl.getAvatar(hash); + + expect(avatarURL).toEqual(`/avatar/${hash}?size=16`); + }); + + it("returns a Gravatar URL if given config has avatar kind set to Gravatar", () => { + configMock['AVATAR_KIND'] = 'gravatar'; + var avatarURL: string = avatarServiceImpl.getAvatar(hash); + + expect(avatarURL).toEqual(`//www.gravatar.com/avatar/${hash}?d=404&size=16`); + }); + + it("uses 16 as default size query parameter if not provided", () => { + var size: number = 16; + var avatarURL: string = avatarServiceImpl.getAvatar(hash); + + expect(avatarURL).toEqual(`/avatar/${hash}?size=${size}`); + }); + + it("uses 404 as default not found query parameter for Gravatar URL if not provided", () => { + configMock['AVATAR_KIND'] = 'gravatar'; + var notFound: string = '404'; + var avatarURL: string = avatarServiceImpl.getAvatar(hash); + + expect(avatarURL).toEqual(`//www.gravatar.com/avatar/${hash}?d=${notFound}&size=16`); + }); + }); + + describe("computeHash", () => { + var email: string; + var name: string; + var expectedHash: string; + + beforeEach(() => { + email = "some_example@gmail.com"; + name = "example"; + expectedHash = "a1b2c3d4e5f6"; + md5Mock.createHash = jasmine.createSpy('createHashSpy').and.returnValue(expectedHash); + }); + + it("returns hash from cache if it exists", () => { + // Call once to set the cache + avatarServiceImpl.computeHash(email, name); + md5Mock.createHash.calls.reset(); + avatarServiceImpl.computeHash(email, name); + + expect(md5Mock.createHash).not.toHaveBeenCalled(); + }); + + it("calls MD5 service to create hash using given email if cache is not set", () => { + avatarServiceImpl.computeHash(email, name); + + expect(md5Mock.createHash.calls.argsFor(0)[0]).toEqual(email.toString().toLowerCase()); + }); + + it("adds first character of given name to hash if config has avatar kind set to local", () => { + var hash: string = avatarServiceImpl.computeHash(email, name); + + expect(hash[0]).toEqual(name[0]); + }); + + it("adds first character of given email to hash if config has avatar kind set to local and not given name", () => { + var hash: string = avatarServiceImpl.computeHash(email); + + expect(hash[0]).toEqual(email[0]); + }); + + it("adds nothing to hash if config avatar kind is not set to local", () => { + configMock['AVATAR_KIND'] = 'gravatar'; + var hash: string = avatarServiceImpl.computeHash(email); + + expect(hash).toEqual(expectedHash); + }); + }); +}); \ No newline at end of file diff --git a/static/js/services/avatar/avatar.service.impl.ts b/static/js/services/avatar/avatar.service.impl.ts new file mode 100644 index 000000000..673cec473 --- /dev/null +++ b/static/js/services/avatar/avatar.service.impl.ts @@ -0,0 +1,49 @@ +import { AvatarService } from './avatar.service'; +import { Injectable } from 'angular-ts-decorators'; + + +@Injectable(AvatarService.name) +export class AvatarServiceImpl implements AvatarService { + + private cache: {[cacheKey: string]: string} = {}; + + constructor(private Config: any, private md5: any) { + + } + + public getAvatar(hash: string, size: number = 16, notFound: string = '404'): string { + var avatarURL: string; + switch (this.Config['AVATAR_KIND']) { + case 'local': + avatarURL = `/avatar/${hash}?size=${size}`; + break; + + case 'gravatar': + avatarURL = `//www.gravatar.com/avatar/${hash}?d=${notFound}&size=${size}`; + break; + } + + return avatarURL; + } + + public computeHash(email: string = '', name: string = ''): string { + const cacheKey: string = email + ':' + name; + + if (this.cache[cacheKey]) { + return this.cache[cacheKey]; + } + + var hash: string = this.md5.createHash(email.toString().toLowerCase()); + switch (this.Config['AVATAR_KIND']) { + case 'local': + if (name) { + hash = name[0] + hash; + } else if (email) { + hash = email[0] + hash; + } + break; + } + + return this.cache[cacheKey] = hash; + } +} \ No newline at end of file diff --git a/static/js/services/avatar/avatar.service.ts b/static/js/services/avatar/avatar.service.ts new file mode 100644 index 000000000..ec817e9fd --- /dev/null +++ b/static/js/services/avatar/avatar.service.ts @@ -0,0 +1,22 @@ +/** + * Service which provides helper methods for retrieving the avatars displayed in the app. + */ +export abstract class AvatarService { + + /** + * Retrieve URL for avatar image with given hash. + * @param hash Avatar image hash. + * @param size Avatar image size. + * @param notFound URL parameter if avatar image is not found. + * @return avatarURL The URL for the avatar image. + */ + public abstract getAvatar(hash: string, size?: number, notFound?: string): string; + + /** + * Compute the avatar image hash. + * @param email Email for avatar user. + * @param name Username for avatar user. + * @return hash The hash for the avatar image. + */ + public abstract computeHash(email?: string, name?: string): string; +} \ No newline at end of file diff --git a/static/js/services/build-service.js b/static/js/services/build-service.js deleted file mode 100644 index 0f68edec2..000000000 --- a/static/js/services/build-service.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Service which provides helper methods for reasoning about builds. - */ -angular.module('quay').factory('BuildService', [function() { - var buildService = {}; - buildService.isActive = function(build) { - return build.phase != 'complete' && build.phase != 'error' && build.phase != 'expired' && build.phase != 'cancelled'; - }; - - buildService.getBuildMessage = function(phase) { - switch (phase) { - case 'cannot_load': - return 'Cannot load build status'; - - case 'starting': - case 'initializing': - return 'Starting Dockerfile build'; - - case 'waiting': - return 'Waiting for available build worker'; - - case 'unpacking': - return 'Unpacking build package'; - - case 'pulling': - return 'Pulling base image'; - - case 'building': - return 'Building image from Dockerfile'; - - case 'checking-cache': - return 'Looking up cached images'; - - case 'priming-cache': - return 'Priming cache for build'; - - case 'build-scheduled': - return 'Preparing build node'; - - case 'pushing': - return 'Pushing image built from Dockerfile'; - - case 'complete': - return 'Dockerfile build completed and pushed'; - - case 'error': - return 'Dockerfile build failed'; - - case 'expired': - return 'Build did not complete after 3 attempts. Re-submit this build to try again.'; - - case 'internalerror': - return 'An internal system error occurred while building; the build will be retried in the next few minutes.'; - - case 'cancelled': - return 'This build was previously cancelled.'; - - } - }; - - return buildService; -}]); diff --git a/static/js/services/build/build.service.impl.spec.ts b/static/js/services/build/build.service.impl.spec.ts new file mode 100644 index 000000000..48c5aa841 --- /dev/null +++ b/static/js/services/build/build.service.impl.spec.ts @@ -0,0 +1,76 @@ +import { BuildServiceImpl } from './build.service.impl'; + + +describe("BuildServiceImpl", () => { + var buildServiceImpl: BuildServiceImpl; + var build: {phase: string}; + + beforeEach(() => { + buildServiceImpl = new BuildServiceImpl(); + build = {phase: ""}; + }); + + describe("isActive", () => { + var phases: string[]; + + beforeEach(() => { + phases = ['complete', 'error', 'expired', 'cancelled']; + }); + + it("returns false if given build's phase matches an inactive phase", () => { + phases.forEach((phase: string) => { + build.phase = phase; + + expect(buildServiceImpl.isActive(build)).toBe(false); + }); + }); + + it("returns true if given build's phase does not match inactive phases", () => { + build.phase = 'initializing'; + + expect(buildServiceImpl.isActive(build)).toBe(true); + }); + }); + + describe("getBuildMessage", () => { + var buildMessages: {phase: string, message: string}[]; + + beforeEach(() => { + buildMessages = [ + {phase: 'cannot_load', message: 'Cannot load build status'}, + {phase: 'starting', message: 'Starting Dockerfile build'}, + {phase: 'initializing', message: 'Starting Dockerfile build'}, + {phase: 'waiting', message: 'Waiting for available build worker'}, + {phase: 'unpacking', message: 'Unpacking build package'}, + {phase: 'pulling', message: 'Pulling base image'}, + {phase: 'building', message: 'Building image from Dockerfile'}, + {phase: 'checking-cache', message: 'Looking up cached images'}, + {phase: 'priming-cache', message: 'Priming cache for build'}, + {phase: 'build-scheduled', message: 'Preparing build node'}, + {phase: 'pushing', message: 'Pushing image built from Dockerfile'}, + {phase: 'complete', message: 'Dockerfile build completed and pushed'}, + {phase: 'error', message: 'Dockerfile build failed'}, + {phase: 'expired', message: 'Build did not complete after 3 attempts. Re-submit this build to try again.'}, + {phase: 'internalerror', message: 'An internal system error occurred while building; the build will be retried in the next few minutes.'}, + {phase: 'cancelled', message: 'This build was previously cancelled.'}, + ]; + }); + + it("returns the correct message for the given phase", () => { + buildMessages.forEach((buildMessage) => { + expect(buildServiceImpl.getBuildMessage(buildMessage.phase)).toEqual(buildMessage.message, buildMessage); + }); + }); + + it("throws an error if given phase is not supported", () => { + var phase: string = "not-a-phase"; + + try { + buildServiceImpl.getBuildMessage(phase); + fail("Should throw error"); + } catch (error) { + expect(error.message).toEqual("Invalid build phase"); + } + }); + }); +}); \ No newline at end of file diff --git a/static/js/services/build/build.service.impl.ts b/static/js/services/build/build.service.impl.ts new file mode 100644 index 000000000..05d08dd62 --- /dev/null +++ b/static/js/services/build/build.service.impl.ts @@ -0,0 +1,84 @@ +import { BuildService } from './build.service'; +import { Injectable } from 'angular-ts-decorators'; + + +@Injectable(BuildService.name) +export class BuildServiceImpl implements BuildService { + + private inactivePhases: string[] = ['complete', 'error', 'expired', 'cancelled']; + + public isActive(build: {phase: string}): boolean { + return this.inactivePhases.indexOf(build.phase) == -1; + } + + public getBuildMessage(phase: string): string { + var message: string; + switch (phase) { + case 'cannot_load': + message = 'Cannot load build status'; + break; + + case 'starting': + case 'initializing': + message = 'Starting Dockerfile build'; + break; + + case 'waiting': + message = 'Waiting for available build worker'; + break; + + case 'unpacking': + message = 'Unpacking build package'; + break; + + case 'pulling': + message = 'Pulling base image'; + break; + + case 'building': + message = 'Building image from Dockerfile'; + break; + + case 'checking-cache': + message = 'Looking up cached images'; + break; + + case 'priming-cache': + message = 'Priming cache for build'; + break; + + case 'build-scheduled': + message = 'Preparing build node'; + break; + + case 'pushing': + message = 'Pushing image built from Dockerfile'; + break; + + case 'complete': + message = 'Dockerfile build completed and pushed'; + break; + + case 'error': + message = 'Dockerfile build failed'; + break; + + case 'expired': + message = 'Build did not complete after 3 attempts. Re-submit this build to try again.'; + break; + + case 'internalerror': + message = 'An internal system error occurred while building; the build will be retried in the next few minutes.'; + break; + + case 'cancelled': + message = 'This build was previously cancelled.'; + break; + + default: + throw new Error("Invalid build phase"); + } + + return message; + } +} \ No newline at end of file diff --git a/static/js/services/build/build.service.ts b/static/js/services/build/build.service.ts new file mode 100644 index 000000000..d07f9d111 --- /dev/null +++ b/static/js/services/build/build.service.ts @@ -0,0 +1,19 @@ +/** + * Service which provides helper methods for reasoning about builds. + */ +export abstract class BuildService { + + /** + * Determine if the given build is active. + * @param build The build object. + * @return isActive If the given build is active. + */ + public abstract isActive(build: {phase: string}): boolean; + + /** + * Generate a message based on a given phase. + * @param phase The phase type. + * @return buildMessage The message associated with the given phase. + */ + public abstract getBuildMessage(phase: string): string; +} \ No newline at end of file diff --git a/static/js/services/datafile-service.js b/static/js/services/datafile-service.js deleted file mode 100644 index 6461a8e99..000000000 --- a/static/js/services/datafile-service.js +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Service which provides helper methods for downloading a data file from a URL, and extracting - * its contents as .tar, .tar.gz, or .zip file. Note that this service depends on external - * library code in the lib/ directory: - * - jszip.min.js - * - Blob.js - * - zlib.js - */ -angular.module('quay').factory('DataFileService', [function() { - var dataFileService = {}; - - dataFileService.getName_ = function(filePath) { - var parts = filePath.split('/'); - return parts[parts.length - 1]; - }; - - dataFileService.tryAsZip_ = function(buf, success, failure) { - var zip = null; - var zipFiles = null; - try { - var zip = new JSZip(buf); - zipFiles = zip.files; - } catch (e) { - failure(); - return; - } - - var files = []; - for (var filePath in zipFiles) { - if (zipFiles.hasOwnProperty(filePath)) { - files.push({ - 'name': dataFileService.getName_(filePath), - 'path': filePath, - 'canRead': true, - 'toBlob': (function(fp) { - return function() { - return new Blob([zip.file(fp).asArrayBuffer()]); - }; - }(filePath)) - }); - } - } - - success(files); - }; - - dataFileService.tryAsTarGz_ = function(buf, success, failure) { - var gunzip = new Zlib.Gunzip(new Uint8Array(buf)); - var plain = null; - - try { - plain = gunzip.decompress(); - } catch (e) { - failure(); - return; - } - - if (plain.byteLength == 0) { - plain = buf; - } - - dataFileService.tryAsTar_(plain, success, failure); - }; - - dataFileService.tryAsTar_ = function(buf, success, failure) { - var collapsePath = function(originalPath) { - // Tar files can contain entries of the form './', so we need to collapse - // those paths down. - var parts = originalPath.split('/'); - for (var i = parts.length - 1; i >= 0; i--) { - var part = parts[i]; - if (part == '.') { - parts.splice(i, 1); - } - } - return parts.join('/'); - }; - - var handler = new Untar(new Uint8Array(buf)); - handler.process(function(status, read, files, err) { - switch (status) { - case 'error': - failure(err); - break; - - case 'done': - var processed = []; - for (var i = 0; i < files.length; ++i) { - var currentFile = files[i]; - var path = collapsePath(currentFile.meta.filename); - - if (path == '' || path == 'pax_global_header') { continue; } - - processed.push({ - 'name': dataFileService.getName_(path), - 'path': path, - 'canRead': true, - 'toBlob': (function(currentFile) { - return function() { - return new Blob([currentFile.buffer], {type: 'application/octet-binary'}); - }; - }(currentFile)) - }); - } - success(processed); - break; - } - }); - }; - - dataFileService.blobToString = function(blob, callback) { - var reader = new FileReader(); - reader.onload = function(event){ - callback(reader.result); - }; - reader.readAsText(blob); - }; - - dataFileService.arrayToString = function(buf, callback) { - var bb = new Blob([buf], {type: 'application/octet-binary'}); - var f = new FileReader(); - f.onload = function(e) { - callback(e.target.result); - }; - f.onerror = function(e) { - callback(null); - }; - f.onabort = function(e) { - callback(null); - }; - f.readAsText(bb); - }; - - dataFileService.readDataArrayAsPossibleArchive = function(buf, success, failure) { - dataFileService.tryAsZip_(buf, success, function() { - dataFileService.tryAsTarGz_(buf, success, function() { - dataFileService.tryAsTar_(buf, success, failure); - }); - }); - }; - - dataFileService.downloadDataFileAsArrayBuffer = function($scope, url, progress, error, loaded) { - var request = new XMLHttpRequest(); - request.open('GET', url, true); - request.responseType = 'arraybuffer'; - - request.onprogress = function(e) { - $scope.$apply(function() { - var percentLoaded; - if (e.lengthComputable) { - progress(e.loaded / e.total); - } - }); - }; - - request.onerror = function() { - $scope.$apply(function() { - error(); - }); - }; - - request.onload = function() { - if (this.status == 200) { - $scope.$apply(function() { - var uint8array = new Uint8Array(request.response); - loaded(uint8array); - }); - return; - } - }; - - request.send(); - }; - - return dataFileService; -}]); diff --git a/static/js/services/datafile/datafile.service.impl.spec.ts b/static/js/services/datafile/datafile.service.impl.spec.ts new file mode 100644 index 000000000..62539a794 --- /dev/null +++ b/static/js/services/datafile/datafile.service.impl.spec.ts @@ -0,0 +1,124 @@ +import { DataFileServiceImpl } from './datafile.service.impl'; +import { Mock } from 'ts-mocks'; +import Spy = jasmine.Spy; + + +describe("DataFileServiceImpl", () => { + var dataFileServiceImpl: DataFileServiceImpl; + var fileReaderMock: Mock; + var fileReader: FileReader; + + beforeEach(() => { + fileReaderMock = new Mock(); + fileReader = fileReaderMock.Object; + dataFileServiceImpl = new DataFileServiceImpl(() => fileReader); + }); + + describe("blobToString", () => { + var data: any; + var blob: Blob; + + beforeEach(() => { + data = {hello: "world"}; + blob = new Blob([JSON.stringify(data)]); + + fileReaderMock.setup(mock => mock.readAsText).is((blob: Blob) => { + fileReaderMock.Object.onload({target: {result: data}}); + }); + }); + + it("calls file reader to read given blob", (done) => { + dataFileServiceImpl.blobToString(blob, (result) => { + expect((fileReader.readAsText).calls.argsFor(0)[0]).toEqual(blob); + done(); + }); + }); + + it("calls given callback with null if file reader errors", (done) => { + fileReaderMock.setup(mock => mock.readAsText).is((blob: Blob) => { + fileReaderMock.Object.onerror(new ErrorEvent("onerror")); + }); + + dataFileServiceImpl.blobToString(blob, (result) => { + expect(result).toBe(null); + done(); + }); + }); + + it("calls given callback with null if file reader aborts", (done) => { + fileReaderMock.setup(mock => mock.readAsText).is((blob: Blob) => { + fileReaderMock.Object.onabort(new Event("onabort")); + }); + + dataFileServiceImpl.blobToString(blob, (result) => { + expect(result).toBe(null); + done(); + }); + }); + + it("calls given callback with result when file reader successfully loads", (done) => { + dataFileServiceImpl.blobToString(blob, (result) => { + expect(result).toBe(data); + done(); + }); + }); + }); + + describe("arrayToString", () => { + var blob: Blob; + var data: any; + + beforeEach(() => { + data = JSON.stringify({hello: "world"}); + blob = new Blob([data], {type: 'application/octet-binary'}); + + fileReaderMock.setup(mock => mock.readAsText).is((blob: Blob) => { + fileReaderMock.Object.onload({target: {result: data}}); + }); + }); + + it("calls file reader to read blob created from given buffer", (done) => { + dataFileServiceImpl.arrayToString(data, (result) => { + expect((fileReader.readAsText).calls.argsFor(0)[0]).toEqual(blob); + done(); + }); + }); + + it("calls given callback with null if file reader errors", (done) => { + fileReaderMock.setup(mock => mock.readAsText).is((blob: Blob) => { + fileReaderMock.Object.onerror(new ErrorEvent("onerror")); + }); + + dataFileServiceImpl.arrayToString(data, (result) => { + expect(result).toEqual(null); + done(); + }); + }); + + it("calls given callback with null if file reader aborts", (done) => { + fileReaderMock.setup(mock => mock.readAsText).is((blob: Blob) => { + fileReaderMock.Object.onabort(new Event("onabort")); + }); + + dataFileServiceImpl.arrayToString(data, (result) => { + expect(result).toEqual(null); + done(); + }); + }); + + it("calls given callback with result when file reader successfully loads", (done) => { + dataFileServiceImpl.arrayToString(data, (result) => { + expect(result).toEqual(data); + done(); + }); + }); + }); + + describe("readDataArrayAsPossibleArchive", () => { + // TODO + }); + + describe("downloadDataFileAsArrayBuffer", () => { + // TODO + }); +}); diff --git a/static/js/services/datafile/datafile.service.impl.ts b/static/js/services/datafile/datafile.service.impl.ts new file mode 100644 index 000000000..be5ce180f --- /dev/null +++ b/static/js/services/datafile/datafile.service.impl.ts @@ -0,0 +1,182 @@ +import { DataFileService } from './datafile.service'; +import { Injectable } from 'angular-ts-decorators'; +declare const JSZip: (buf: any) => void; +declare const Zlib: any; +declare const Untar: (uint8Array: Uint8Array) => void; + + +@Injectable(DataFileService.name) +export class DataFileServiceImpl implements DataFileService { + + constructor(private fileReaderFactory: () => FileReader) { + + } + + public blobToString(blob: Blob, callback: (result: string) => void): void { + var reader: FileReader = this.fileReaderFactory(); + reader.onload = (event: Event) => callback(event.target['result']); + reader.onerror = (event: Event) => callback(null); + reader.onabort = (event: Event) => callback(null); + reader.readAsText(blob); + } + + public arrayToString(buf: any, callback: (result: string) => void): void { + const blob: Blob = new Blob([buf], {type: 'application/octet-binary'}); + var reader: FileReader = this.fileReaderFactory(); + reader.onload = (event: Event) => callback(event.target['result']); + reader.onerror = (event: Event) => callback(null); + reader.onabort = (event: Event) => callback(null); + reader.readAsText(blob); + } + + public readDataArrayAsPossibleArchive(buf: any, + success: (result: any) => void, + failure: (error: any) => void): void { + this.tryAsZip(buf, success, () => { + this.tryAsTarGz(buf, success, () => { + this.tryAsTar(buf, success, failure); + }); + }); + } + + public downloadDataFileAsArrayBuffer($scope: ng.IScope, + url: string, + progress: (percent: number) => void, + error: () => void, + loaded: (uint8array: Uint8Array) => void): void { + var request: XMLHttpRequest = new XMLHttpRequest(); + request.open('GET', url, true); + request.responseType = 'arraybuffer'; + + request.onprogress = (e) => { + $scope.$apply(() => { + var percentLoaded; + if (e.lengthComputable) { + progress(e.loaded / e.total); + } + }); + }; + + request.onerror = () => { + $scope.$apply(() => { + error(); + }); + }; + + request.onload = function() { + if (request.status == 200) { + $scope.$apply(() => { + var uint8array = new Uint8Array(request.response); + loaded(uint8array); + }); + return; + } + }; + + request.send(); + } + + private getName(filePath: string): string { + var parts: string[] = filePath.split('/'); + + return parts[parts.length - 1]; + } + + private tryAsZip(buf: any, success: (result: any) => void, failure: (error?: any) => void): void { + var zip = null; + var zipFiles = null; + try { + var zip = new JSZip(buf); + zipFiles = zip.files; + } catch (e) { + failure(); + return; + } + + var files = []; + for (var filePath in zipFiles) { + if (zipFiles.hasOwnProperty(filePath)) { + files.push({ + 'name': this.getName(filePath), + 'path': filePath, + 'canRead': true, + 'toBlob': (function(fp) { + return function() { + return new Blob([zip.file(fp).asArrayBuffer()]); + }; + }(filePath)) + }); + } + } + + success(files); + } + + private tryAsTarGz(buf: any, success: (result: any) => void, failure: (error?: any) => void): void { + var gunzip = new Zlib.Gunzip(new Uint8Array(buf)); + var plain = null; + + try { + plain = gunzip.decompress(); + } catch (e) { + failure(); + return; + } + + if (plain.byteLength == 0) { + plain = buf; + } + + this.tryAsTar(plain, success, failure); + } + + private tryAsTar(buf: any, success: (result: any) => void, failure: (error?: any) => void): void { + var collapsePath = function(originalPath) { + // Tar files can contain entries of the form './', so we need to collapse + // those paths down. + var parts = originalPath.split('/'); + for (var i = parts.length - 1; i >= 0; i--) { + var part = parts[i]; + if (part == '.') { + parts.splice(i, 1); + } + } + return parts.join('/'); + }; + + try { + var handler = new Untar(new Uint8Array(buf)); + handler.process((status, read, files, err) => { + switch (status) { + case 'error': + failure(err); + break; + + case 'done': + var processed = []; + for (var i = 0; i < files.length; ++i) { + var currentFile = files[i]; + var path = collapsePath(currentFile.meta.filename); + + if (path == '' || path == 'pax_global_header') { continue; } + + processed.push({ + 'name': this.getName(path), + 'path': path, + 'canRead': true, + 'toBlob': (function(currentFile) { + return function() { + return new Blob([currentFile.buffer], {type: 'application/octet-binary'}); + }; + }(currentFile)) + }); + } + success(processed); + break; + } + }); + } catch (e) { + failure(); + } + } +} \ No newline at end of file diff --git a/static/js/services/datafile/datafile.service.ts b/static/js/services/datafile/datafile.service.ts new file mode 100644 index 000000000..47c990d83 --- /dev/null +++ b/static/js/services/datafile/datafile.service.ts @@ -0,0 +1,48 @@ +/** + * Service which provides helper methods for downloading a data file from a URL, and extracting + * its contents as .tar, .tar.gz, or .zip file. Note that this service depends on external + * library code in the lib/ directory: + * - jszip.min.js + * - Blob.js + * - zlib.js + */ +export abstract class DataFileService { + + /** + * Convert a blob to a string. + * @param blob The blob to convert. + * @param callback The success callback given converted blob. + */ + public abstract blobToString(blob: Blob, callback: (result: string) => void): void; + + /** + * Convert array to string. + * @param buf The array buffer to convert. + * @param callback The success callback given converted array buffer. + */ + public abstract arrayToString(buf: any, callback: (result: string) => void): void; + + /** + * Determine if a given data array is an archive file. + * @param buf The data array to check. + * @param success The success callback if the given array is an archive file, given the file contents. + * @param failure The failure callback if the given array is not an archive file, given the error message. + */ + public abstract readDataArrayAsPossibleArchive(buf: any, + success: (result: any) => void, + failure: (error: any) => void): void; + + /** + * Download a file into an array buffer while tracking progress. + * @param $scope An AngularJS $scope instance. + * @param url The URL of the file to be downloaded. + * @param progress The callback for download progress. + * @param error The error callback. + * @param loaded The success callback given the downloaded array buffer. + */ + public abstract downloadDataFileAsArrayBuffer($scope: ng.IScope, + url: string, + progress: (percent: number) => void, + error: () => void, + loaded: (uint8array: Uint8Array) => void): void; +} \ No newline at end of file diff --git a/static/js/services/dockerfile-service.js b/static/js/services/dockerfile-service.js deleted file mode 100644 index 8763b8a13..000000000 --- a/static/js/services/dockerfile-service.js +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Service which provides helper methods for extracting information out from a Dockerfile - * or an archive containing a Dockerfile. - */ -angular.module('quay').factory('DockerfileService', ['DataFileService', 'Config', function(DataFileService, Config) { - var dockerfileService = {}; - - function DockerfileInfo(contents) { - this.contents = contents; - } - - DockerfileInfo.prototype.getRegistryBaseImage = function() { - var baseImage = this.getBaseImage(); - if (!baseImage) { - return null; - } - - if (baseImage.indexOf(Config.getDomain() + '/') != 0) { - return null; - } - - return baseImage.substring(Config.getDomain().length + 1); - }; - - DockerfileInfo.prototype.getBaseImage = function() { - var imageAndTag = this.getBaseImageAndTag(); - if (!imageAndTag) { - return null; - } - - // Note, we have to handle a few different cases here: - // 1) someimage - // 2) someimage:tag - // 3) host:port/someimage - // 4) host:port/someimage:tag - var lastIndex = imageAndTag.lastIndexOf(':'); - if (lastIndex < 0) { - return imageAndTag; - } - - // Otherwise, check if there is a / in the portion after the split point. If so, - // then the latter is part of the path (and not a tag). - var afterColon = imageAndTag.substring(lastIndex + 1); - if (afterColon.indexOf('/') >= 0) { - return imageAndTag; - } - - return imageAndTag.substring(0, lastIndex); - }; - - DockerfileInfo.prototype.getBaseImageAndTag = function() { - var fromIndex = this.contents.indexOf('FROM '); - if (fromIndex < 0) { - return null; - } - - var newline = this.contents.indexOf('\n', fromIndex); - if (newline < 0) { - newline = this.contents.length; - } - - return $.trim(this.contents.substring(fromIndex + 'FROM '.length, newline)); - }; - - DockerfileInfo.forData = function(contents) { - if (contents.indexOf('FROM ') < 0) { - return; - } - - return new DockerfileInfo(contents); - }; - - var processFiles = function(files, dataArray, success, failure) { - // The files array will be empty if the submitted file was not an archive. We therefore - // treat it as a single Dockerfile. - if (files.length == 0) { - DataFileService.arrayToString(dataArray, function(c) { - var result = DockerfileInfo.forData(c); - if (!result) { - failure('File chosen is not a valid Dockerfile'); - return; - } - - success(result); - }); - return; - } - - var found = false; - files.forEach(function(file) { - if (file['name'] == 'Dockerfile') { - DataFileService.blobToString(file.toBlob(), function(c) { - var result = DockerfileInfo.forData(c); - if (!result) { - failure('Dockerfile inside archive is not a valid Dockerfile'); - return; - } - - success(result); - }); - found = true; - } - }); - - if (!found) { - failure('No Dockerfile found in root of archive'); - } - }; - - dockerfileService.getDockerfile = function(file, success, failure) { - var reader = new FileReader(); - reader.onload = function(e) { - var dataArray = reader.result; - DataFileService.readDataArrayAsPossibleArchive(dataArray, function(files) { - processFiles(files, dataArray, success, failure); - }, function() { - // Not an archive. Read directly as a single file. - processFiles([], dataArray, success, failure); - }); - }; - - reader.onerror = failure; - reader.readAsArrayBuffer(file); - }; - - return dockerfileService; -}]); \ No newline at end of file diff --git a/static/js/services/dockerfile/dockerfile.service.impl.spec.ts b/static/js/services/dockerfile/dockerfile.service.impl.spec.ts new file mode 100644 index 000000000..01eefe3e2 --- /dev/null +++ b/static/js/services/dockerfile/dockerfile.service.impl.spec.ts @@ -0,0 +1,292 @@ +import { DockerfileServiceImpl, DockerfileInfoImpl } from './dockerfile.service.impl'; +import { DataFileService } from '../datafile/datafile.service'; +import Spy = jasmine.Spy; +import { Mock } from 'ts-mocks'; + + +describe("DockerfileServiceImpl", () => { + var dockerfileServiceImpl: DockerfileServiceImpl; + var dataFileServiceMock: Mock; + var dataFileService: DataFileService; + var configMock: any; + var fileReaderMock: Mock; + + beforeEach(() => { + dataFileServiceMock = new Mock(); + dataFileService = dataFileServiceMock.Object; + configMock = jasmine.createSpyObj('configMock', ['getDomain']); + fileReaderMock = new Mock(); + dockerfileServiceImpl = new DockerfileServiceImpl(dataFileService, configMock, () => fileReaderMock.Object); + }); + + describe("getDockerfile", () => { + var file: any; + var invalidArchiveFile: any[]; + var validArchiveFile: any[]; + var forDataSpy: Spy; + + beforeEach(() => { + file = "FROM quay.io/coreos/nginx:latest"; + validArchiveFile = [{name: 'Dockerfile', toBlob: jasmine.createSpy('toBlobSpy').and.returnValue(file)}]; + invalidArchiveFile = [{name: 'main.exe', toBlob: jasmine.createSpy('toBlobSpy').and.returnValue("")}]; + + dataFileServiceMock.setup(mock => mock.readDataArrayAsPossibleArchive).is((buf, success, failure) => { + failure([]); + }); + + dataFileServiceMock.setup(mock => mock.arrayToString).is((buf, callback) => callback("")); + + dataFileServiceMock.setup(mock => mock.blobToString).is((blob, callback) => callback(blob.toString())); + + forDataSpy = spyOn(DockerfileInfoImpl, "forData").and.returnValue(new DockerfileInfoImpl(file, configMock)); + + fileReaderMock.setup(mock => mock.readAsArrayBuffer).is((blob: Blob) => { + fileReaderMock.Object.onload({target: {result: file}}); + }); + }); + + it("calls datafile service to read given file as possible archive file", (done) => { + dockerfileServiceImpl.getDockerfile(file) + .then((dockerfile: DockerfileInfoImpl) => { + expect((fileReaderMock.Object.readAsArrayBuffer).calls.argsFor(0)[0]).toEqual(file); + expect(dataFileService.readDataArrayAsPossibleArchive).toHaveBeenCalled(); + done(); + }) + .catch((error: string) => { + fail('Promise should be resolved'); + done(); + }); + }); + + it("calls datafile service to convert file to string if given file is not an archive", (done) => { + dockerfileServiceImpl.getDockerfile(file) + .then((dockerfile: DockerfileInfoImpl) => { + expect((dataFileService.arrayToString).calls.argsFor(0)[0]).toEqual(file); + done(); + }) + .catch((error: string) => { + fail('Promise should be resolved'); + done(); + }); + }); + + it("returns rejected promise if given non-archive file that is not a valid Dockerfile", (done) => { + forDataSpy.and.returnValue(null); + dockerfileServiceImpl.getDockerfile(file) + .then((dockerfile: DockerfileInfoImpl) => { + fail("Promise should be rejected"); + done(); + }) + .catch((error: string) => { + expect(error).toEqual('File chosen is not a valid Dockerfile'); + done(); + }); + }); + + it("returns resolved promise with new DockerfileInfoImpl instance if given valid Dockerfile", (done) => { + dockerfileServiceImpl.getDockerfile(file) + .then((dockerfile: DockerfileInfoImpl) => { + expect(dockerfile).toBeDefined(); + done(); + }) + .catch((error: string) => { + fail('Promise should be resolved'); + done(); + }); + }); + + it("returns rejected promise if given archive file with no Dockerfile present in root directory", (done) => { + dataFileServiceMock.setup(mock => mock.readDataArrayAsPossibleArchive).is((buf, success, failure) => { + success(invalidArchiveFile); + }); + + dockerfileServiceImpl.getDockerfile(file) + .then((dockerfile: DockerfileInfoImpl) => { + fail('Promise should be rejected'); + done(); + }) + .catch((error: string) => { + expect(error).toEqual('No Dockerfile found in root of archive'); + done(); + }); + }); + + it("calls datafile service to convert blob to string if given file is an archive", (done) => { + dataFileServiceMock.setup(mock => mock.readDataArrayAsPossibleArchive).is((buf, success, failure) => { + success(validArchiveFile); + }); + + dockerfileServiceImpl.getDockerfile(file) + .then((dockerfile: DockerfileInfoImpl) => { + expect(validArchiveFile[0].toBlob).toHaveBeenCalled(); + expect((dataFileService.blobToString).calls.argsFor(0)[0]).toEqual(validArchiveFile[0].toBlob()); + done(); + }) + .catch((error: string) => { + fail('Promise should be resolved'); + done(); + }); + }); + + it("returns rejected promise if given archive file with invalid Dockerfile", (done) => { + forDataSpy.and.returnValue(null); + invalidArchiveFile[0].name = 'Dockerfile'; + dataFileServiceMock.setup(mock => mock.readDataArrayAsPossibleArchive).is((buf, success, failure) => { + success(invalidArchiveFile); + }); + + dockerfileServiceImpl.getDockerfile(file) + .then((dockerfile: DockerfileInfoImpl) => { + fail('Promise should be rejected'); + done(); + }) + .catch((error: string) => { + expect(error).toEqual('Dockerfile inside archive is not a valid Dockerfile'); + done(); + }); + }); + + it("returns resolved promise of new DockerfileInfoImpl instance if given archive with valid Dockerfile", (done) => { + dataFileServiceMock.setup(mock => mock.readDataArrayAsPossibleArchive).is((buf, success, failure) => { + success(validArchiveFile); + }); + + dockerfileServiceImpl.getDockerfile(file) + .then((dockerfile: DockerfileInfoImpl) => { + expect(dockerfile).toBeDefined(); + done(); + }) + .catch((error: string) => { + fail('Promise should be resolved'); + done(); + }); + }); + }); +}); + + +describe("DockerfileInfoImpl", () => { + var dockerfileInfoImpl: DockerfileInfoImpl; + var contents: string; + var configMock: any; + + beforeEach(() => { + contents = ""; + configMock = jasmine.createSpyObj('configMock', ['getDomain']); + dockerfileInfoImpl = new DockerfileInfoImpl(contents, configMock); + }); + + describe("forData", () => { + + it("returns null if given contents do not contain a 'FROM' command", () => { + expect(DockerfileInfoImpl.forData(contents, configMock)).toBe(null); + }); + + it("returns a new DockerfileInfoImpl instance if given contents are valid", () => { + contents = "FROM quay.io/coreos/nginx"; + + expect(DockerfileInfoImpl.forData(contents, configMock) instanceof DockerfileInfoImpl).toBe(true); + }); + }); + + describe("getRegistryBaseImage", () => { + var domain: string; + var baseImage: string; + + beforeEach(() => { + domain = "quay.io"; + baseImage = "coreos/nginx"; + + configMock.getDomain.and.returnValue(domain); + }); + + it("returns null if instance's contents do not contain a 'FROM' command", () => { + var getBaseImageSpy: Spy = spyOn(dockerfileInfoImpl, "getBaseImage").and.returnValue(null); + + expect(dockerfileInfoImpl.getRegistryBaseImage()).toBe(null); + expect(getBaseImageSpy).toHaveBeenCalled(); + }); + + it("returns null if the domain of the instance's config does not match that of the base image", () => { + configMock.getDomain.and.returnValue(domain); + spyOn(dockerfileInfoImpl, "getBaseImage").and.returnValue('host.com'); + + expect(dockerfileInfoImpl.getRegistryBaseImage()).toBe(null); + expect(configMock.getDomain).toHaveBeenCalled(); + }); + + it("returns the registry base image", () => { + spyOn(dockerfileInfoImpl, "getBaseImage").and.returnValue(`${domain}/${baseImage}`); + + expect(dockerfileInfoImpl.getRegistryBaseImage()).toEqual(baseImage); + }); + }); + + describe("getBaseImage", () => { + var host: string; + var port: number; + var tag: string; + var image: string; + + beforeEach(() => { + host = 'quay.io'; + port = 80; + tag = 'latest'; + image = 'coreos/nginx'; + }); + + it("returns null if instance's contents do not contain a 'FROM' command", () => { + var getBaseImageAndTagSpy: Spy = spyOn(dockerfileInfoImpl, "getBaseImageAndTag").and.returnValue(null); + + expect(dockerfileInfoImpl.getBaseImage()).toBe(null); + expect(getBaseImageAndTagSpy).toHaveBeenCalled(); + }); + + it("returns the image name if in the format 'someimage'", () => { + spyOn(dockerfileInfoImpl, "getBaseImageAndTag").and.returnValue(image); + + expect(dockerfileInfoImpl.getBaseImage()).toEqual(image); + }); + + it("returns the image name if in the format 'someimage:tag'", () => { + spyOn(dockerfileInfoImpl, "getBaseImageAndTag").and.returnValue(`${image}:${tag}`); + + expect(dockerfileInfoImpl.getBaseImage()).toEqual(image); + }); + + it("returns the host, port, and image name if in the format 'host:port/someimage'", () => { + spyOn(dockerfileInfoImpl, "getBaseImageAndTag").and.returnValue(`${host}:${port}/${image}`); + + expect(dockerfileInfoImpl.getBaseImage()).toEqual(`${host}:${port}/${image}`); + }); + + it("returns the host, port, and image name if in the format 'host:port/someimage:tag'", () => { + spyOn(dockerfileInfoImpl, "getBaseImageAndTag").and.returnValue(`${host}:${port}/${image}:${tag}`); + + expect(dockerfileInfoImpl.getBaseImage()).toEqual(`${host}:${port}/${image}`); + }); + }); + + describe("getBaseImageAndTag", () => { + + it("returns null if instance's contents do not contain a 'FROM' command", () => { + expect(dockerfileInfoImpl.getBaseImageAndTag()).toBe(null); + }); + + it("returns a string containing the base image and tag from the instance's contents", () => { + contents = "FROM quay.io/coreos/nginx"; + dockerfileInfoImpl = new DockerfileInfoImpl(contents, configMock); + var baseImageAndTag: string = dockerfileInfoImpl.getBaseImageAndTag(); + + expect(baseImageAndTag).toEqual(contents.substring('FROM '.length, contents.length).trim()); + }); + + it("handles the presence of newlines", () => { + contents = "FROM quay.io/coreos/nginx\nRUN echo $0"; + dockerfileInfoImpl = new DockerfileInfoImpl(contents, configMock); + var baseImageAndTag: string = dockerfileInfoImpl.getBaseImageAndTag(); + + expect(baseImageAndTag).toEqual(contents.substring('FROM '.length, contents.indexOf('\n')).trim()); + }); + }); +}); diff --git a/static/js/services/dockerfile/dockerfile.service.impl.ts b/static/js/services/dockerfile/dockerfile.service.impl.ts new file mode 100644 index 000000000..54adc51c8 --- /dev/null +++ b/static/js/services/dockerfile/dockerfile.service.impl.ts @@ -0,0 +1,155 @@ +import { DockerfileService, DockerfileInfo } from './dockerfile.service'; +import { Injectable } from 'angular-ts-decorators'; +import { DataFileService } from '../datafile/datafile.service'; + + +@Injectable(DockerfileService.name) +export class DockerfileServiceImpl implements DockerfileService { + + constructor(private DataFileService: DataFileService, + private Config: any, + private fileReaderFactory: () => FileReader) { + + } + + public getDockerfile(file: any): Promise { + return new Promise((resolve, reject) => { + var reader: FileReader = this.fileReaderFactory(); + reader.onload = (event: any) => { + this.DataFileService.readDataArrayAsPossibleArchive(event.target.result, + (files: any[]) => { + if (files.length > 0) { + this.processFiles(files) + .then((dockerfileInfo: DockerfileInfoImpl) => resolve(dockerfileInfo)) + .catch((error: string) => reject(error)); + } + // Not an archive. Read directly as a single file. + else { + this.processFile(event.target.result) + .then((dockerfileInfo: DockerfileInfoImpl) => resolve(dockerfileInfo)) + .catch((error: string) => reject(error)); + } + }, + () => { + // Not an archive. Read directly as a single file. + this.processFile(event.target.result) + .then((dockerfileInfo: DockerfileInfoImpl) => resolve(dockerfileInfo)) + .catch((error: string) => reject(error)); + }); + }; + + reader.onerror = (event: any) => reject(event); + reader.readAsArrayBuffer(file); + }); + } + + private processFile(dataArray: any): Promise { + return new Promise((resolve, reject) => { + this.DataFileService.arrayToString(dataArray, (contents: string) => { + var result: DockerfileInfoImpl | null = DockerfileInfoImpl.forData(contents, this.Config); + if (result == null) { + reject('File chosen is not a valid Dockerfile'); + } + else { + resolve(result); + } + }); + }); + } + + private processFiles(files: any[]): Promise { + return new Promise((resolve, reject) => { + var found: boolean = false; + files.forEach((file) => { + if (file['name'] == 'Dockerfile') { + this.DataFileService.blobToString(file.toBlob(), (contents: string) => { + var result: DockerfileInfoImpl | null = DockerfileInfoImpl.forData(contents, this.Config); + if (result == null) { + reject('Dockerfile inside archive is not a valid Dockerfile'); + } + else { + resolve(result); + } + }); + found = true; + } + }); + + if (!found) { + reject('No Dockerfile found in root of archive'); + } + }); + } +} + + +export class DockerfileInfoImpl implements DockerfileInfo { + + constructor(private contents: string, private config: any) { + + } + + public static forData(contents: string, config: any): DockerfileInfoImpl | null { + var dockerfileInfo: DockerfileInfoImpl = null; + if (contents.indexOf('FROM ') != -1) { + dockerfileInfo = new DockerfileInfoImpl(contents, config); + } + + return dockerfileInfo; + } + + public getRegistryBaseImage(): string | null { + var baseImage = this.getBaseImage(); + if (!baseImage) { + return null; + } + + if (baseImage.indexOf(this.config.getDomain() + '/') != 0) { + return null; + } + + return baseImage.substring(this.config.getDomain().length + 1); + } + + public getBaseImage(): string | null { + const imageAndTag = this.getBaseImageAndTag(); + if (!imageAndTag) { + return null; + } + + // Note, we have to handle a few different cases here: + // 1) someimage + // 2) someimage:tag + // 3) host:port/someimage + // 4) host:port/someimage:tag + const lastIndex: number = imageAndTag.lastIndexOf(':'); + if (lastIndex == -1) { + return imageAndTag; + } + + // Otherwise, check if there is a / in the portion after the split point. If so, + // then the latter is part of the path (and not a tag). + const afterColon: string = imageAndTag.substring(lastIndex + 1); + if (afterColon.indexOf('/') != -1) { + return imageAndTag; + } + + return imageAndTag.substring(0, lastIndex); + } + + public getBaseImageAndTag(): string | null { + var baseImageAndTag: string = null; + + const fromIndex: number = this.contents.indexOf('FROM '); + if (fromIndex != -1) { + var newlineIndex: number = this.contents.indexOf('\n', fromIndex); + if (newlineIndex == -1) { + newlineIndex = this.contents.length; + } + + baseImageAndTag = this.contents.substring(fromIndex + 'FROM '.length, newlineIndex).trim(); + } + + return baseImageAndTag; + } +} \ No newline at end of file diff --git a/static/js/services/dockerfile/dockerfile.service.ts b/static/js/services/dockerfile/dockerfile.service.ts new file mode 100644 index 000000000..3c5186b6b --- /dev/null +++ b/static/js/services/dockerfile/dockerfile.service.ts @@ -0,0 +1,38 @@ +/** + * Service which provides helper methods for extracting information out from a Dockerfile + * or an archive containing a Dockerfile. + */ +export abstract class DockerfileService { + + /** + * Retrieve Dockerfile from given file. + * @param file Dockerfile or archive file containing Dockerfile. + * @return promise Promise which resolves to new DockerfileInfo instance or rejects with error message. + */ + public abstract getDockerfile(file: any): Promise; +} + + +/** + * Model representing information about a specific Dockerfile. + */ +export abstract class DockerfileInfo { + + /** + * Extract the registry base image from the Dockerfile contents. + * @return registryBaseImage The registry base image. + */ + public abstract getRegistryBaseImage(): string | null; + + /** + * Extract the base image from the Dockerfile contents. + * @return baseImage The base image. + */ + public abstract getBaseImage(): string | null; + + /** + * Extract the base image and tag from the Dockerfile contents. + * @return baseImageAndTag The base image and tag. + */ + public abstract getBaseImageAndTag(): string | null; +} \ No newline at end of file diff --git a/static/js/services/page/page.service.impl.spec.ts b/static/js/services/page/page.service.impl.spec.ts index 61f9e51f1..efce5c752 100644 --- a/static/js/services/page/page.service.impl.spec.ts +++ b/static/js/services/page/page.service.impl.spec.ts @@ -9,14 +9,14 @@ describe("Service: PageServiceImpl", () => { }); describe("create", () => { - + // TODO }); describe("get", () => { - + // TODO }); describe("$get", () => { - + // TODO }); }); \ No newline at end of file diff --git a/static/js/services/view-array/view-array.ts b/static/js/services/view-array/view-array.ts index fbdfaf4db..4b7abbd35 100644 --- a/static/js/services/view-array/view-array.ts +++ b/static/js/services/view-array/view-array.ts @@ -1,4 +1,6 @@ import { ViewArrayImpl } from "static/js/services/view-array/view-array.impl"; + + /** * Specialized wrapper around array which provides a toggle() method for viewing the contents of the * array in a manner that is asynchronously filled in over a short time period. This prevents long diff --git a/static/test/test-index.ts b/static/test/test-index.ts new file mode 100644 index 000000000..5a30fd447 --- /dev/null +++ b/static/test/test-index.ts @@ -0,0 +1,6 @@ +declare var require: any; + + +// Require all modules ending in ".spec.ts" from the js directory and all subdirectories +var testsContext = require.context("../js", true, /\.spec\.ts$/); +testsContext.keys().forEach(testsContext); diff --git a/test/data/test.db b/test/data/test.db index dc4f5960a..c583ad3a6 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/webpack.config.js b/webpack.config.js index 788f51445..68f51889c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -21,7 +21,9 @@ var config = { rules: [ { test: /\.tsx?$/, - loader: "ts-loader", + use: [ + "ts-loader", + ], exclude: /node_modules/ }, { diff --git a/yarn.lock b/yarn.lock index ba8e378b6..bfeb362ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14,6 +14,12 @@ dependencies: "@types/angular" "*" +"@types/angular-sanitize@^1.3.4": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@types/angular-sanitize/-/angular-sanitize-1.3.4.tgz#aa63bd32a1c3c8846e16d66be891a5bdb3b0fc65" + dependencies: + "@types/angular" "*" + "@types/angular@*", "@types/angular@1.6.2": version "1.6.2" resolved "https://registry.yarnpkg.com/@types/angular/-/angular-1.6.2.tgz#a5c323ea5d4426ad18984cc8167fa091f7c8201b" @@ -790,7 +796,7 @@ core-js@^1.0.0: version "1.2.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" -core-js@^2.1.0: +core-js@^2.1.0, core-js@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" @@ -3874,6 +3880,10 @@ ts-loader@^0.9.5: object-assign "^4.1.0" semver "^5.0.1" +ts-mocks@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/ts-mocks/-/ts-mocks-0.2.2.tgz#051e5b3a30068f6b9f1b1faa552a6f172793c6d6" + ts-node@^1.2.1: version "1.7.3" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-1.7.3.tgz#dee7f8a84751732d3c2e497cac5a02fb117dfee7"