cloudfoundry/stratos

View on GitHub
src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts

Summary

Maintainability
C
1 day
Test Coverage
import { Store } from '@ngrx/store';
import { BehaviorSubject, of as observableOf, Subject, Subscription } from 'rxjs';
import websocketConnect from 'rxjs-websockets';
import { catchError, combineLatest, filter, first, map, mergeMap, share, switchMap, tap } from 'rxjs/operators';

import { CFAppState } from '../../../../../cloud-foundry/src/cf-app-state';
import { organizationEntityType, spaceEntityType } from '../../../../../cloud-foundry/src/cf-entity-types';
import { selectCfEntity } from '../../../../../cloud-foundry/src/store/selectors/api.selectors';
import { selectDeployAppState } from '../../../../../cloud-foundry/src/store/selectors/deploy-application.selector';
import {
  AppData,
  DeployApplicationSource,
  DeployApplicationState,
  OverrideAppDetails,
  SocketEventTypes,
} from '../../../../../cloud-foundry/src/store/types/deploy-application.types';
import { environment } from '../../../../../core/src/environments/environment.prod';
import { CfOrgSpaceDataService } from '../../../shared/data-services/cf-org-space-service.service';
import { FileScannerInfo } from './deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner';
import { DEPLOY_TYPES_IDS } from './deploy-application-steps.types';


export interface DeployApplicationDeployerStatus {
  error: boolean;
  errorMsg?: string;
  deploying: boolean;
}

export interface FileTransferStatus {
  totalFiles: number;
  filesSent: number;
  bytesSent: number;
  totalBytes: number;
  fileName: string;
}

interface DeploySource {
  type: string;
}

interface GitSCMSourceInfo extends DeploySource {
  project: string;
  branch: string;
  url: string;
  commit: string;
  scm: string;
  endpointGuid: string;
}

// Structure used to provide metadata about the Git Url source
interface GitUrlSourceInfo extends DeploySource {
  branch: string;
  url: string;
}

// DockerImageSourceInfo - Structure used to provide metadata about the docker source
interface DockerImageSourceInfo extends DeploySource {
  applicationName: string;
  dockerImage: string;
  dockerUsername: string;
}

interface FolderSourceInfo extends DeploySource {
  wait: boolean;
  files: number;
  folders: string[];
}

export class DeployApplicationDeployer {

  isRedeploy: string;
  connectSub: Subscription;
  updateSub: Subscription;
  msgSub: Subscription;
  streamTitle = 'Preparing...';
  appData: AppData;
  proxyAPIVersion = environment.proxyAPIVersion;
  cfGuid: string;
  orgGuid: string;
  spaceGuid: string;
  applicationSource: any;
  applicationOverrides: OverrideAppDetails;

  status$ = new BehaviorSubject<DeployApplicationDeployerStatus>({
    error: false,
    deploying: false
  });

  // Observable on the application GUID of the application being deployed
  applicationGuid$ = new BehaviorSubject<string>(null);

  // Status of file transfers
  fileTransferStatus$ = new BehaviorSubject<FileTransferStatus>(undefined);

  public messages = new BehaviorSubject<string>('');

  // Are we deploying?
  deploying = false;

  private inputStream;

  private isOpen = false;

  public fsFileInfo: FileScannerInfo;

  private fileTransfers;
  private fileTransferStatus: FileTransferStatus;
  private currentFileTransfer;

  constructor(
    private store: Store<CFAppState>,
    public cfOrgSpaceService: CfOrgSpaceDataService,
  ) { }

  updateStatus(error = false, errorMsg?: string) {
    this.status$.next({
      error,
      errorMsg,
      deploying: this.deploying
    });
  }

  close() {
    // Unsubscribe from the websocket stream
    if (this.msgSub) {
      this.msgSub.unsubscribe();
    }
    if (this.connectSub) {
      this.connectSub.unsubscribe();
    }
    if (this.updateSub) {
      this.updateSub.unsubscribe();
    }
    this.isOpen = false;
    this.currentFileTransfer = undefined;
  }

  open() {
    if (this.isOpen) {
      return;
    }

    const readyFilter = this.fsFileInfo ?
      () => true :
      (appDetail: DeployApplicationState) => {
        if (!appDetail.applicationSource || !appDetail.applicationOverrides) {
          return;
        }
        return (!!appDetail.applicationSource.gitDetails && !!appDetail.applicationSource.gitDetails.projectName) ||
          (!!appDetail.applicationSource.dockerDetails && !!appDetail.applicationSource.dockerDetails.dockerImage);

      };
    this.isOpen = true;
    this.connectSub = this.store.select(selectDeployAppState).pipe(
      filter((appDetail: DeployApplicationState) => !!appDetail.cloudFoundryDetails && readyFilter(appDetail)),
      mergeMap(appDetails => {
        const orgSubscription = this.store.select(selectCfEntity(organizationEntityType, appDetails.cloudFoundryDetails.org));
        const spaceSubscription = this.store.select(selectCfEntity(spaceEntityType, appDetails.cloudFoundryDetails.space));
        return observableOf(appDetails).pipe(combineLatest(orgSubscription, spaceSubscription));
      }),
      first(),
      tap(([appDetail, org, space]) => {
        this.cfGuid = appDetail.cloudFoundryDetails.cloudFoundry;
        this.orgGuid = appDetail.cloudFoundryDetails.org;
        this.spaceGuid = appDetail.cloudFoundryDetails.space;
        this.applicationSource = appDetail.applicationSource;
        this.applicationOverrides = appDetail.applicationOverrides;
        const host = window.location.host;
        const appId = this.isRedeploy ? `&app=${this.isRedeploy}` : '';
        const streamUrl = (
          `wss://${host}/pp/${this.proxyAPIVersion}/${this.cfGuid}/${this.orgGuid}/${this.spaceGuid}/deploy` +
          `?org=${org.entity.name}&space=${space.entity.name}${appId}`
        );

        this.inputStream = new Subject<string>();
        const buffer = websocketConnect(streamUrl)
          .pipe(
            switchMap((get) => get(this.inputStream)),
            catchError(e => {
              return [];
            }),
            filter(l => !!l),
            map(log => JSON.parse(log)),
            tap((log) => {
              // Deal with control messages
              if (log.type !== SocketEventTypes.DATA) {
                this.processWebSocketMessage(log);
              }
            }),
            filter((log) => log.type === SocketEventTypes.DATA),
            map((log) => log.message),
            share(),
          );

        // Buffer messages until each newline character
        let b = '';
        this.msgSub = buffer.subscribe(m => {
          b = b + m;
          if (b.endsWith('\n')) {
            this.messages.next(b);
            b = '';
          }
        });
      })
    ).subscribe();

    // Watch for updates to the app overrides - use case is app overrides being set after source file/folder upload
    this.updateSub = this.store.select(selectDeployAppState).pipe(
      filter((appDetail: DeployApplicationState) => !!appDetail.cloudFoundryDetails && readyFilter(appDetail)),
      tap((appDetail) => {
        this.applicationOverrides = appDetail.applicationOverrides;
      })
    ).subscribe();
  }

  deploy() {
    // After the source has been sent, acknowledge the wait
    const msg = {
      message: '',
      timestamp: Math.round((new Date()).getTime() / 1000),
      type: SocketEventTypes.SOURCE_WAIT_ACK
    };
    this.inputStream.next(JSON.stringify(msg));
  }

  sendAppOverride = (appOverrides: OverrideAppDetails) => {
    const msg = {
      message: JSON.stringify(appOverrides),
      timestamp: Math.round((new Date()).getTime() / 1000),
      type: SocketEventTypes.OVERRIDES_SUPPLIED
    };
    return JSON.stringify(msg);
  };

  sendProjectInfo = (appSource: DeployApplicationSource) => {
    if (appSource.type.group === 'gitscm') {
      return this.sendGitSCMSourceMetadata(appSource);
    } else if (appSource.type.id === DEPLOY_TYPES_IDS.GIT_URL) {
      return this.sendGitUrlSourceMetadata(appSource);
    } else if (appSource.type.id === DEPLOY_TYPES_IDS.FILE || appSource.type.id === DEPLOY_TYPES_IDS.FOLDER) {
      return this.sendLocalSourceMetadata();
    } else if (appSource.type.id === DEPLOY_TYPES_IDS.DOCKER_IMG) {
      return this.sendDockerImageMetadata(appSource);
    }
    return '';
  };

  sendGitSCMSourceMetadata = (appSource: DeployApplicationSource) => {
    const gitscm: GitSCMSourceInfo = {
      project: appSource.gitDetails.projectName,
      branch: appSource.gitDetails.branch.name,
      type: appSource.type.group,
      commit: appSource.gitDetails.commit,
      url: appSource.gitDetails.url,
      scm: appSource.type.id,
      endpointGuid: appSource.gitDetails.endpointGuid
    };

    const msg = {
      message: JSON.stringify(gitscm),
      timestamp: Math.round((new Date()).getTime() / 1000),
      type: SocketEventTypes.SOURCE_GITSCM
    };
    return JSON.stringify(msg);
  };

  sendGitUrlSourceMetadata = (appSource: DeployApplicationSource) => {
    const gitUrl: GitUrlSourceInfo = {
      url: appSource.gitDetails.projectName,
      branch: appSource.gitDetails.branch.name,
      type: appSource.type.id
    };

    const msg = {
      message: JSON.stringify(gitUrl),
      timestamp: Math.round((new Date()).getTime() / 1000),
      type: SocketEventTypes.SOURCE_GITURL
    };
    return JSON.stringify(msg);
  };

  sendDockerImageMetadata = (appSource: DeployApplicationSource) => {
    const dockerInfo: DockerImageSourceInfo = {
      applicationName: appSource.dockerDetails.applicationName,
      dockerImage: appSource.dockerDetails.dockerImage,
      dockerUsername: appSource.dockerDetails.dockerUsername,
      type: appSource.type.id
    };

    const msg = {
      message: JSON.stringify(dockerInfo),
      timestamp: Math.round((new Date()).getTime() / 1000),
      type: SocketEventTypes.SOURCE_DOCKER_IMG
    };
    return JSON.stringify(msg);
  };

  sendCloseAcknowledgement = () => {
    const msg = {
      message: '{}',
      timestamp: Math.round((new Date()).getTime() / 1000),
      type: SocketEventTypes.CLOSE_ACK
    };
    return JSON.stringify(msg);
  };

  processWebSocketMessage = (log) => {
    switch (log.type) {
      case SocketEventTypes.MANIFEST:
        this.streamTitle = 'Starting deployment...';
        // This info is will be used to retrieve the app Id
        this.appData = JSON.parse(log.message).Applications[0];
        break;
      case SocketEventTypes.APP_GUID_NOTIFY:
        // Notification of the application GUID for the application
        this.applicationGuid$.next(log.message);
        break;
      case SocketEventTypes.EVENT_PUSH_STARTED:
        this.streamTitle = 'Deploying...';
        this.deploying = true;
        this.updateStatus();
        break;
      case SocketEventTypes.EVENT_PUSH_COMPLETED:
        // Done
        this.streamTitle = 'Deployed';
        this.deploying = false;
        this.updateStatus();
        break;
      case SocketEventTypes.CLOSE_SUCCESS:
        // Acknowledge
        this.inputStream.next(this.sendCloseAcknowledgement());
        this.onClose(log, null, null);
        break;
      case SocketEventTypes.CLOSE_INVALID_MANIFEST:
        this.onClose(log, 'Deploy Failed - Invalid manifest!',
          'Failed to deploy app! Please make sure that a valid manifest.yaml was provided.');
        break;
      case SocketEventTypes.CLOSE_NO_MANIFEST:
        this.onClose(log, 'Deploy Failed - No manifest present!',
          'Failed to deploy app! Please make sure that a valid manifest.yaml is present.');
        break;
      case SocketEventTypes.CLOSE_FAILED_CLONE:
        this.onClose(log, 'Deploy Failed - Failed to clone repository!',
          'Failed to deploy app! Please make sure the repository is accessible.');
        break;
      case SocketEventTypes.CLOSE_FAILED_NO_BRANCH:
        this.onClose(log, 'Deploy Failed - Failed to located branch!',
          'Failed to deploy app! Please make sure that the branch exists.');
        break;
      case SocketEventTypes.CLOSE_FAILURE:
      case SocketEventTypes.CLOSE_PUSH_ERROR:
      case SocketEventTypes.CLOSE_NO_SESSION:
      case SocketEventTypes.CLOSE_NO_CNSI:
      case SocketEventTypes.CLOSE_NO_CNSI_USERTOKEN:
        this.onClose(log, 'Deploy Failed!',
          'Failed to deploy app!');
        break;
      case SocketEventTypes.SOURCE_REQUIRED:
        const sourceInfo = this.sendProjectInfo(this.applicationSource);
        if (!sourceInfo) {
          this.onClose(log, 'Deploy Failed - Unknown source type',
            'Failed to deploy the app - unknown source type');
        } else {
          this.inputStream.next(sourceInfo);
        }
        break;
      case SocketEventTypes.OVERRIDES_REQUIRED:
        const overrides = this.sendAppOverride(this.applicationOverrides);
        this.inputStream.next(overrides);
        break;
      case SocketEventTypes.EVENT_CLONED:
      case SocketEventTypes.EVENT_FETCHED_MANIFEST:
      case SocketEventTypes.MANIFEST:
        break;
      case SocketEventTypes.SOURCE_FILE_ACK:
        this.sendNextFile();
        break;
      default:
      // noop
    }
  };

  private sendNextFile() {
    // Update for the previous file transfer
    if (this.currentFileTransfer) {
      this.fileTransferStatus.bytesSent += this.currentFileTransfer.size;
      this.fileTransferStatus.filesSent++;
      this.fileTransferStatus$.next(this.fileTransferStatus);
    }

    if (this.fileTransfers.length > 0) {
      const file = this.fileTransfers.shift();

      // Send file metadata
      const msg = {
        message: file.fullPath,
        timestamp: Math.round((new Date()).getTime() / 1000),
        type: SocketEventTypes.SOURCE_FILE
      };

      this.fileTransferStatus.fileName = file.fullPath;
      this.fileTransferStatus$.next(this.fileTransferStatus);

      // Send the file name metadata
      this.inputStream.next(JSON.stringify(msg));

      // Now send the file data as a binary message
      const reader = new FileReader();
      reader.onload = () => {
        this.currentFileTransfer = file;
        const output = reader.result;
        this.inputStream.next(output);
      };
      reader.readAsArrayBuffer(file);
    }
  }

  private onClose(log, title, error) {
    if (title) {
      this.streamTitle = title;
    }
    this.deploying = false;
    this.updateStatus(
      error,
      error ? `${error}\nReason: ${log.message}` : undefined
    );
  }

  // File Upload
  sendLocalSourceMetadata() {
    const metadata = {
      files: [],
      folders: []
    };

    this.collectFoldersAndFiles(metadata, null, this.fsFileInfo.root);

    this.fileTransfers = metadata.files;
    this.fileTransferStatus = {
      totalFiles: metadata.files.length,
      filesSent: 0,
      bytesSent: 0,
      totalBytes: this.fsFileInfo.total,
      fileName: ''
    };

    this.fileTransferStatus$.next(this.fileTransferStatus);

    const transferMetadata: FolderSourceInfo = {
      files: metadata.files.length,
      folders: metadata.folders,
      type: 'filefolder',
      wait: true
    };

    // Send the source metadata
    return JSON.stringify({
      message: JSON.stringify(transferMetadata),
      timestamp: Math.round((new Date()).getTime() / 1000),
      type: SocketEventTypes.SOURCE_FOLDER
    });
  }

  // Flatten files and folders
  collectFoldersAndFiles(metadata, base, folder) {
    if (folder.files) {
      folder.files.forEach(file => {
        file.fullPath = base ? base + '/' + file.name : file.name;
        metadata.files.push(file);
      });
    }

    if (folder.folders) {
      Object.keys(folder.folders).forEach(name => {
        const sub = folder.folders[name];
        const fullPath = base ? base + '/' + name : name;
        metadata.folders.push(fullPath);
        this.collectFoldersAndFiles(metadata, fullPath, sub);
      });
    }
  }

}