import { DigitalOceanSession , DropletInfo } from '../cloud/digitalocean_api' ; import * as errors from '../infrastructure/errors' ; import { hexToString } from '../infrastructure/hex_encoding' ; import { sleep } from '../infrastructure/sleep' ; import { ValueStream } from "../infrastructure/value_stream" ; import { Region } from '../model/digitalocean' ; import * as server from '../model/server' ; import { ShadowboxServer } from './shadowbox_server' ; const KEY_VALUE_TAG = 'kv' ; const INSTALL_STARTED_TAG = 'install-started' ; const CERTIFICATE_FINGERPRINT_TAG = 'certsha256' ; const API_URL_TAG = 'apiurl' ; const INSTALL_ERROR_TAG = 'install-error' ; const DEPRECATED_API_PORT_TAG = 'apiport' ; const DEPRECATED_API_PREFIX_TAG = 'apiprefix' ; enum InstallState { UNKNOWN = 0 , DROPLET_CREATED , DROPLET_RUNNING , CERTIFICATE_CREATED , COMPLETED , FAILED , CANCELED } function getCompletionFraction ( state ) { switch ( state ) { case InstallState . UNKNOWN : return 0.1 ; case InstallState . DROPLET_CREATED : return 0.5 ; case InstallState . DROPLET_RUNNING : return 0.55 ; case InstallState . CERTIFICATE_CREATED : return 0.6 ; case InstallState . COMPLETED : return 1.0 ; default : return 0 ; } } function isFinal ( state ) { return state === InstallState . COMPLETED || state === InstallState . FAILED || state === InstallState . CANCELED ; } export class DigitalOceanServer extends ShadowboxServer implements server . ManagedServer { private onDropletActive : ( ) => void ; readonly onceDropletActive = new Promise < void > ( fulfill => { this . onDropletActive = fulfill ; } ) ; private installState = new ValueStream < InstallState > ( InstallState . UNKNOWN ) ; private readonly startTimestamp = Date . now ( ) ; constructor ( id , private digitalOcean , private dropletInfo ) { super ( id ) ; console . info ( 'DigitalOceanServer created' ) ; this . updateInstallState ( ) ; this . pollInstallState ( ) ; } async * monitorInstallProgress ( ) { for await ( const state of this . installState . watch ( ) ) { yield getCompletionFraction ( state ) ; } if ( this . installState . get ( ) === InstallState . FAILED ) { throw new errors . ServerInstallFailedError ( ) ; } else if ( this . installState . get ( ) === InstallState . CANCELED ) { throw new errors . ServerInstallCanceledError ( ) ; } } private updateInstallState ( ) { const TIMEOUT_MS = 5 * 60 * 1000 ; const tagMap = this . getTagMap ( ) ; if ( tagMap . get ( INSTALL_ERROR_TAG ) ) { console . error ( `error tag: ${ tagMap . get ( INSTALL_ERROR_TAG ) }` ) ; this . setInstallState ( InstallState . FAILED ) ; } else if ( Date . now ( ) - this . startTimestamp >= TIMEOUT_MS ) { console . error ( 'hit timeout while waiting for installation' ) ; this . setInstallState ( InstallState . FAILED ) ; } else if ( this . setApiUrlAndCertificate ( ) ) { console . info ( 'digitalocean_server: Successfully found API and cert tags' ) ; this . setInstallState ( InstallState . COMPLETED ) ; } else if ( tagMap . get ( CERTIFICATE_FINGERPRINT_TAG ) ) { this . setInstallState ( InstallState . CERTIFICATE_CREATED ) ; } else if ( tagMap . get ( INSTALL_STARTED_TAG ) ) { this . setInstallState ( InstallState . DROPLET_RUNNING ) ; } else if ( this . dropletInfo ?. status === 'active' ) { this . setInstallState ( InstallState . DROPLET_CREATED ) ; } } private async pollInstallState ( ) { while ( ! this . installState . isClosed ( ) ) { try { await this . refreshDropletInfo ( ) ; } catch ( error ) { console . log ( 'Failed to get droplet info' , error ) ; this . setInstallState ( InstallState . FAILED ) ; return ; } this . updateInstallState ( ) ; if ( this . installState . isClosed ( ) ) { return ; } await sleep ( 3000 ) ; } } private setInstallState ( installState ) { this . installState . set ( installState ) ; if ( isFinal ( installState ) ) { this . installState . close ( ) ; } } private setApiUrlAndCertificate ( ) { try { const certificateFingerprint = this . getCertificateFingerprint ( ) ; const apiAddress = this . getManagementApiAddress ( ) ; trustCertificate ( certificateFingerprint ) ; this . setManagementApiUrl ( apiAddress ) ; return true ; } catch ( e ) { return false ; } } private async refreshDropletInfo ( ) { const newDropletInfo = await this . digitalOcean . getDroplet ( this . dropletInfo . id ) ; const oldDropletInfo = this . dropletInfo ; this . dropletInfo = newDropletInfo ; if ( newDropletInfo . status !== oldDropletInfo . status ) { if ( newDropletInfo . status === 'active' ) { this . onDropletActive ( ) ; } } } private getTagMap ( ) { const ret = new Map < string , string > ( ) ; const tagPrefix = KEY_VALUE_TAG + ':' ; for ( const tag of this . dropletInfo . tags ) { if ( ! startsWithCaseInsensitive ( tag , tagPrefix ) ) { continue ; } const keyValuePair = tag . slice ( tagPrefix . length ) ; const [ key , hexValue ] = keyValuePair . split ( ':' , 2 ) ; try { ret . set ( key . toLowerCase ( ) , hexToString ( hexValue ) ) ; } catch ( e ) { console . error ( 'error decoding hex string' ) ; } } return ret ; } private ipv4Address ( ) { for ( const network of this . dropletInfo . networks . v4 ) { if ( network . type === 'public' ) { return network . ip_address ; } } return undefined ; } private getManagementApiAddress ( ) { const tagMap = this . getTagMap ( ) ; let apiAddress = tagMap . get ( API_URL_TAG ) ; if ( ! apiAddress ) { const portNumber = tagMap . get ( DEPRECATED_API_PORT_TAG ) ; if ( ! portNumber ) { throw new Error ( 'Could not get API port number' ) ; } if ( ! this . ipv4Address ( ) ) { throw new Error ( 'API hostname not set' ) ; } apiAddress = `https://${ this . ipv4Address ( ) }:${ portNumber }/` ; const apiPrefix = tagMap . get ( DEPRECATED_API_PREFIX_TAG ) ; if ( apiPrefix ) { apiAddress += apiPrefix + '/' ; } } if ( ! apiAddress . endsWith ( '/' ) ) { apiAddress += '/' ; } return apiAddress ; } private getCertificateFingerprint ( ) { const fingerprint = this . getTagMap ( ) . get ( CERTIFICATE_FINGERPRINT_TAG ) ; if ( fingerprint ) { return btoa ( fingerprint ) ; } else { throw new Error ( 'certificate fingerprint unavailable' ) ; } } getHost ( ) { return new DigitalOceanHost ( this . digitalOcean , this . dropletInfo , this . onDelete . bind ( this ) ) ; } private onDelete ( ) { if ( ! this . installState . isClosed ( ) ) { this . setInstallState ( InstallState . CANCELED ) ; } } } class DigitalOceanHost implements server . ManagedServerHost { constructor ( private digitalOcean , private dropletInfo , private deleteCallback ) { } getMonthlyOutboundTransferLimit ( ) { return { terabytes : this . dropletInfo . size . transfer } ; } getMonthlyCost ( ) { return { usd : this . dropletInfo . size . price_monthly } ; } getCloudLocation ( ) { return new Region ( this . dropletInfo . region . slug ) ; } delete ( ) { this . deleteCallback ( ) ; return this . digitalOcean . deleteDroplet ( this . dropletInfo . id ) ; } } function startsWithCaseInsensitive ( text , prefix ) { return text . slice ( 0 , prefix . length ) . toLowerCase ( ) === prefix . toLowerCase ( ) ; }