import { UserError } from "@adpt/utils" ; import * as fs from "fs-extra" ; import { padStart } from "lodash" ; import moment from "moment" ; import { JsonDB } from "node-json-db" ; import * as path from "path" ; import { lock } from "../utils/lockfile" ; import { HistoryEntry , HistoryName , HistoryStatus , HistoryStore , isHistoryStatus , isStatusComplete } from "./history" ; export const dataDirName = "dataDir" ; export const dependenciesFilename = "adapt_dependencies.json" ; export const domFilename = "adapt_dom.xml" ; export const stateFilename = "adapt_state.json" ; export const observationsFilename = "adapt_observations.json" ; export const infoFilename = "adapt_deploy.json" ; interface DirInfo { stateDirs : string [ ] ; } const dirNameRegEx = /^(\d{5})-([^-]+)-(.*)$/ ; function dirStatus ( dirName ) { const m = dirName . match ( dirNameRegEx ) ; if ( m ) { const status = m [ 2 ] ; if ( isHistoryStatus ( status ) ) return status ; } throw new Error ( `History directory '${ dirName }' unrecognized format` ) ; } function dirStatusMatches ( dirName , expected ) { if ( expected === undefined ) return true ; const status = dirStatus ( dirName ) ; if ( expected === HistoryStatus . complete ) return isStatusComplete ( status ) ; else return dirStatus ( dirName ) === expected ; } class LocalHistoryStore implements HistoryStore { dataDir : string ; dataDirRelease ? : ( ) => Promise < void > ; constructor ( private db , private dbPath , private rootDir ) { this . dataDir = path . join ( rootDir , dataDirName + ".uncommitted" ) ; } async init ( create ) { let currentInfo ; try { currentInfo = this . db . getData ( this . dbPath ) ; } catch ( err ) { if ( err . name !== "DataError" ) throw err ; if ( ! create ) throw new Error ( `History store does not exist` ) ; currentInfo = { stateDirs : [ ] , } ; this . db . push ( this . dbPath , currentInfo ) ; } await fs . ensureDir ( this . rootDir ) ; } async destroy ( ) { await this . releaseDataDir ( ) ; try { this . db . delete ( this . dbPath ) ; } catch ( err ) { } try { await fs . remove ( this . rootDir ) ; } catch ( err ) { } } async commitEntry ( toStore ) { const { dependenciesJson , domXml , stateJson , observationsJson , ... info } = toStore ; if ( toStore . dataDir !== this . dataDir ) { throw new Error ( `Internal error: commiting invalid dataDir ` + `'${ toStore . dataDir }'. Should be '${ this . dataDir }'` ) ; } const dirName = await this . nextDirName ( toStore . status ) ; const dirPath = path . join ( this . rootDir , dirName ) ; info . dataDir = path . join ( dirPath , dataDirName ) ; await fs . outputFile ( path . join ( dirPath , domFilename ) , domXml ) ; await fs . outputFile ( path . join ( dirPath , stateFilename ) , stateJson ) ; await fs . outputFile ( path . join ( dirPath , observationsFilename ) , observationsJson ) ; await fs . outputFile ( path . join ( dirPath , dependenciesFilename ) , dependenciesJson ) ; await fs . outputJson ( path . join ( dirPath , infoFilename ) , info ) ; if ( toStore . status === HistoryStatus . preAct ) { await fs . copy ( this . dataDir , info . dataDir , { overwrite : false , errorOnExist : true , preserveTimestamps : true , } ) ; } else { await fs . move ( this . dataDir , info . dataDir ) ; await this . releaseDataDir ( ) ; } this . db . push ( this . dbPath + "/stateDirs[]" , dirName , false ) ; } async last ( withStatus ? ) { const lastDir = this . lastDir ( withStatus ) ; if ( lastDir === undefined ) return undefined ; return this . historyEntry ( lastDir ) ; } async remove ( historyName ) { await fs . remove ( historyName ) ; const info = await this . getInfo ( ) ; info . stateDirs = info . stateDirs . filter ( ( d ) => d !== historyName ) ; await this . setInfo ( info ) ; } async historyEntry ( historyName ) { const dirName = path . join ( this . rootDir , historyName ) ; const domXml = await fs . readFile ( path . join ( dirName , domFilename ) ) ; const stateJson = await fs . readFile ( path . join ( dirName , stateFilename ) ) ; const observationsJson = await fs . readFile ( path . join ( dirName , observationsFilename ) ) ; const info = await fs . readJson ( path . join ( dirName , infoFilename ) ) ; let dependenciesJson = "{}" ; try { dependenciesJson = ( await fs . readFile ( path . join ( dirName , dependenciesFilename ) ) ) . toString ( ) ; } catch ( err ) { if ( err . code !== "ENOENT" ) throw err ; } return { dependenciesJson , domXml : domXml . toString ( ) , stateJson : stateJson . toString ( ) , observationsJson : observationsJson . toString ( ) , fileName : info . fileName , projectRoot : info . projectRoot , stackName : info . stackName , dataDir : info . dataDir , status : info . status , } ; } async getDataDir ( withStatus ) { if ( this . dataDirRelease ) { throw new Error ( `Internal error: attempting to lock dataDir ` + `'${ this . dataDir }' twice` ) ; } await fs . ensureDir ( this . dataDir ) ; try { this . dataDirRelease = await lock ( this . dataDir , "Adapt server history lock" ) ; } catch ( e ) { throw new UserError ( `Unable to get exclusive access to deployment ` + `directory. Please retry in a moment. [${ e . message }]` ) ; } await fs . remove ( this . dataDir ) ; await fs . ensureDir ( this . dataDir ) ; const last = this . lastDir ( withStatus ) ; if ( last ) { await fs . copy ( path . join ( this . rootDir , last , dataDirName ) , this . dataDir , { preserveTimestamps : true , } ) ; } return this . dataDir ; } async releaseDataDir ( ) { if ( ! this . dataDirRelease ) return ; await fs . remove ( this . dataDir ) ; await this . dataDirRelease ( ) ; this . dataDirRelease = undefined ; } private async nextDirName ( status ) { const lastDir = this . lastDir ( ) ; let nextSeq ; if ( lastDir === undefined ) { nextSeq = 0 ; } else { const matches = lastDir . match ( /^\d+/ ) ; if ( matches == null ) throw new Error ( `stateDir entry '${ lastDir }' is invalid` ) ; const lastSeq = parseInt ( matches [ 0 ] , 10 ) ; if ( status === HistoryStatus . preAct ) { nextSeq = lastSeq + 1 ; } else { if ( dirStatus ( lastDir ) !== HistoryStatus . preAct ) { throw new Error ( `Unexpected status for last history entry. ` + `(lastDir: ${ lastDir }, status: ${ status })` ) ; } nextSeq = lastSeq ; } } const seqStr = padStart ( nextSeq . toString ( 10 ) , 5 , "0" ) ; const timestamp = moment ( ) . format ( ) . replace ( /:/g , "." ) ; return `${ seqStr }-${ status }-${ timestamp }` ; } private lastDir ( withStatus ? ) { try { const dirList = this . db . getData ( this . dbPath + "/stateDirs" ) . slice ( ) ; while ( true ) { const dir = dirList . pop ( ) ; if ( dir === undefined || dirStatusMatches ( dir , withStatus ) ) return dir ; } } catch ( err ) { if ( err . name !== "DataError" ) throw err ; return undefined ; } } private async getInfo ( ) { return this . db . getData ( this . dbPath ) ; } private async setInfo ( info ) { return this . db . push ( this . dbPath , info , true ) ; } } export async function createLocalHistoryStore ( db , dbPath , rootDir , create ) { const h = new LocalHistoryStore ( db , dbPath , rootDir ) ; await h . init ( create ) ; return h ; }