From 41cf06842b4c2ccf9f6a640c325afb93e3a7c614 Mon Sep 17 00:00:00 2001 From: Kasey Date: Thu, 16 Jul 2020 15:45:23 -0400 Subject: [PATCH] feat(migration): adjust `BackendProcess`, `main.dev.js` & `App.ts` so we can coordinate migrations! (#571) * refactor(backend.js): emit messages to renderer process during backend launch We refactor the `BackendProcess` class to have specific methods that the main process can use to orchestrate launching the backend app Because we can only emit events to the renderer process in the main process (so no way to emit them in `BackendProcess`, unless we pass the `mainWindow` down to the `BackendProcess` class) this exposes a bunch of launching functionality to the main process (it was previously all hidden in the `backendProcess.maybeStartup()` function) In future refactors, am definitely open to pushing `mainWindow` down to `BackendProcess` and dealing with all aspects down there. However, as a first iteration I think this is fine. The logic for this the start up goes like this: - if a backend process is already running, error. Catch the error & send a message to the renderer that a qri backend is already running - check if we have a compatible backend version. If it's too low, send a message to the renderer that the version is incompatible - check if we need a migration - if we do, send a message to the renderer that we are migrating - launch the backend * refactor(ui reducer): add `bootupComponent` as field on `state.ui` - add `BootupComponentType` - `state.ui.bootupComponet` defaults to 'loading' - add `UI_SET_BOOTUP_COMPONENT` action type - add `setBootstrapComponent` action - add `selectBootstrapComponent` selector * refactor(`main`): need to wait for app to be loaded before coordinating Both the renderer process and main process need to be on before we can successfully coordinate behavior. `App` component now emits an `app-fully-loaded` event. Moved the backend launching process into the ipcMain `app-fully-loaded` event handler. This also means that whenever the app re-loads, we check for a backend connection, and if we don't find one we launch again. Which I think is a nice pick up! Downside is we get slightly longer loading time in dev mode (since it takes a bit to compile the app), but no discernible changes in prod mode! * refactor(backend): saner `checkNeedsMigration` function now that the backend flags/expectations have changed, we can run any comand to see if it exits with code `2` (which indicates that a migration is needed) * refactor(backend.js): use correct flags in `qri connect` & use `qri config` as migration check --- app/actions/ui.ts | 12 ++++- app/backend.js | 110 ++++++++++++++++++++++++++++++++++++---- app/components/App.tsx | 97 ++++++++++++++++++++++++++++++----- app/main.development.js | 48 ++++++++++++++++-- app/models/store.ts | 3 ++ app/reducers/ui.ts | 10 +++- app/selections.ts | 6 ++- 7 files changed, 255 insertions(+), 31 deletions(-) diff --git a/app/actions/ui.ts b/app/actions/ui.ts index f7f89896..8b98509c 100644 --- a/app/actions/ui.ts +++ b/app/actions/ui.ts @@ -9,10 +9,11 @@ import { UI_SET_DATASET_DIR_PATH, UI_SET_EXPORT_PATH, UI_SET_DETAILS_BAR, - UI_SET_IMPORT_FILE_DETAILS + UI_SET_IMPORT_FILE_DETAILS, + UI_SET_BOOTUP_COMPONENT } from '../reducers/ui' -import { ToastType } from '../models/store' +import { ToastType, BootupComponentType } from '../models/store' import { Modal, ModalType } from '../models/modals' import { Details } from '../models/details' @@ -100,3 +101,10 @@ export const setImportFileDetails = (fileName: string, fileSize: number) => { fileSize } } + +export const setBootupComponent = (component: BootupComponentType) => { + return { + type: UI_SET_BOOTUP_COMPONENT, + component + } +} diff --git a/app/backend.js b/app/backend.js index e45420ef..6474924d 100644 --- a/app/backend.js +++ b/app/backend.js @@ -1,10 +1,13 @@ const log = require('electron-log') const childProcess = require('child_process') const fs = require('fs') +const yaml = require('js-yaml') const path = require('path') const os = require('os') +const http = require('http') const { dialog } = require('electron') +const lowestCompatibleBackend = [0, 9, 9] // BackendProcess runs the qri backend binary in connected'ed mode, to handle api requests. class BackendProcess { @@ -12,10 +15,24 @@ class BackendProcess { this.qriBinPath = null this.process = null this.debugLogPath = null + this.backendVer = null // Default to writing to stdout & stderr this.out = process.stdout - this.err = process.stderr + this.in = process.in + this.err = process.stderr; + + [ + 'setQriBinPath', + 'setBackendVer', + 'standardRepoPath', + 'checkNoActiveBackendProcess', + 'checkBackendCompatibility', + 'checkNeedsMigration', + 'launchProcess' + ].forEach((m) => { this[m] = this[m].bind(this) }) + + try { // Create a log whose filename contains the current day. const nowTime = new Date(); @@ -41,9 +58,12 @@ class BackendProcess { detail: err }) } + this.setQriBinPath() + this.setBackendVer() } - maybeStartup () { + // running this function will ensure that a qriBinPath exists + setQriBinPath () { // In development node, use installed qri binary if (process.env.NODE_ENV === 'development') { let processResult = childProcess.execSync('which qri'); @@ -59,12 +79,17 @@ class BackendProcess { } if (!this.qriBinPath) { - log.warn('no qri bin path found, backend launch') - return + log.warn('no qri bin path found, backend launch failed') + throw new Error("no qri bin path found, backend launch failed") } - // Run the binary if it is found log.info(`found qri binary at path: ${this.qriBinPath}`) - this.launchProcess() + } + + setBackendVer () { + let processResult = childProcess.execSync(`"${this.qriBinPath}" version`) + this.backendVer = processResult.toString().trim() + log.info("qri backend version", this.backendVer) + } close () { @@ -74,16 +99,66 @@ class BackendProcess { } } + standardRepoPath() { + var qriRepoPath = process.env.QRI_PATH + if (qriRepoPath === "") { + home = os.homedir() + qriRepoPath = path.join(home, ".qri") + } + + log.info(`QRI_PATH is ${qriRepoPath}`) + return qriRepoPath + } + + async checkNoActiveBackendProcess() { + const healthCheck = async () => { + return new Promise((res, rej) => { + http.get('http://localhost:2503/health', (data) => { + res(true) + }).on('error', (e) => { + res(false) + }) + }) + } + + var isQriRunning = await healthCheck() + if (isQriRunning) { + throw new Error("backend-already-running") + } + return + } + + async checkBackendCompatibility () { + log.info(`checking to see if given backend version ${this.backendVer} is compatible with expected version ${lowestCompatibleBackend.join(".")}`) + let compatible = false + try { + let ver = this.backendVer + if (this.backendVer.indexOf("-dev") !== -1) { + ver = ver.slice(0, this.backendVer.indexOf("-dev")) + } + ver = ver.split(".").map((i) => parseInt(i)) + compatible = lowestCompatibleBackend.every((val, i) => { + if (val <= ver[i]) { + return true + } + return false + }) + } catch (e) { + throw e + } + if (!compatible) { + throw new Error("incompatible-backend") + } + } + launchProcess () { try { - let processResult = childProcess.execSync(`"${this.qriBinPath}" version`) - let qriBinVersion = processResult.toString().trim(); - this.process = childProcess.spawn(this.qriBinPath, ['connect', '--setup', '--log-all'], { stdio: ['ignore', this.out, this.err] }) + this.process = childProcess.spawn(this.qriBinPath, ['connect', '--migrate', '--log-all', '--setup', '--no-prompt'], { stdio: ['ignore', this.out, this.err] }) this.process.on('error', (err) => { this.handleEvent('error', err) }) this.process.on('exit', (err) => { this.handleEvent('exit', err) }) this.process.on('close', (err) => { this.handleEvent('close', err) }) this.process.on('disconnect', (err) => { this.handleEvent('disconnect', err) }) - log.info(`starting up qri backend version ${qriBinVersion}`) + log.info(`starting up qri backend version ${this.backendVer}`) } catch (err) { log.error(`starting background process: ${err}`) } @@ -92,11 +167,26 @@ class BackendProcess { handleEvent (kind, err) { if (err) { log.error(`event ${kind} from backend: ${err}`) + throw err } else { log.warn(`event ${kind} from backend`) } } + checkNeedsMigration() { + log.info("checking for backend migrations") + try { + childProcess.execSync(`"${this.qriBinPath}" config get`) + } catch (err) { + // status code 2 means we need to run a migration + if (err.status == 2) { + return true + } + log.error(`error checking for migration: ${err}`) + } + return false + } + findQriBin (pathList) { for (let i = 0; i < pathList.length; i++) { let binPath = path.join(pathList[i], '/backend/qri') diff --git a/app/components/App.tsx b/app/components/App.tsx index ce1f36cd..35702a94 100644 --- a/app/components/App.tsx +++ b/app/components/App.tsx @@ -13,19 +13,19 @@ import { DEFAULT_POLL_INTERVAL } from '../constants' import { history } from '../store/configureStore.development' import { ApiAction } from '../store/api' import { Modal, ModalType } from '../models/modals' -import { Selections, ApiConnection } from '../models/store' +import { Selections, ApiConnection, BootupComponentType } from '../models/store' import { Session } from '../models/session' // import util funcs import { connectComponentToProps } from '../utils/connectComponentToProps' // import actions -import { setModal } from '../actions/ui' +import { setModal, setBootupComponent } from '../actions/ui' import { pingApi } from '../actions/api' import { bootstrap } from '../actions/session' // import selections -import { selectSelections, selectModal, selectApiConnection, selectSession } from '../selections' +import { selectSelections, selectModal, selectApiConnection, selectSession, selectBootupComponent } from '../selections' // import components import Toast from './Toast' @@ -34,10 +34,14 @@ import AppError from './AppError' import AppLoading from './AppLoading' import Modals from './modals/Modals' import Routes from '../routes' +import MigratingBackend from './MigratingBackend' +import MigrationFailed from './MigrationFailed' +import IncompatibleBackend from './IncompatibleBackend' // declare interface for props export interface AppProps { loading: boolean + bootupComponent: BootupComponentType session: Session selections: Selections apiConnection?: ApiConnection @@ -46,6 +50,7 @@ export interface AppProps { push: (path: string) => void setModal: (modal: Modal) => Action + setBootupComponent: (component: BootupComponentType) => Action bootstrap: () => Promise pingApi: () => Promise @@ -73,6 +78,14 @@ class AppComponent extends React.Component { this.handleReload = this.handleReload.bind(this) this.handleSetDebugLogPath = this.handleSetDebugLogPath.bind(this) this.handleExportDebugLog = this.handleExportDebugLog.bind(this) + this.handleIncompatibleBackend = this.handleIncompatibleBackend.bind(this) + this.handleMigratingBackend = this.handleMigratingBackend.bind(this) + this.handleMigrationFailure = this.handleMigrationFailure.bind(this) + this.handleStartingBackend = this.handleStartingBackend.bind(this) + } + + private handleStartingBackend () { + console.log("started backend message received") } private handleCreateDataset () { @@ -112,6 +125,18 @@ class AppComponent extends React.Component { fs.copyFileSync(this.state.debugLogPath, exportFilename) } + private handleIncompatibleBackend (_: Electron.IpcRendererEvent, ver: string) { + this.props.setBootupComponent(ver) + } + + private handleMigratingBackend () { + this.props.setBootupComponent('migrating') + } + + private handleMigrationFailure () { + this.props.setBootupComponent('migrationFailure') + } + componentDidMount () { // handle ipc events from electron menus ipcRenderer.on('create-dataset', this.handleCreateDataset) @@ -120,6 +145,12 @@ class AppComponent extends React.Component { ipcRenderer.on('set-debug-log-path', this.handleSetDebugLogPath) ipcRenderer.on('export-debug-log', this.handleExportDebugLog) ipcRenderer.on('reload', this.handleReload) + ipcRenderer.on('incompatible-backend', this.handleIncompatibleBackend) + ipcRenderer.on('migrating-backend', this.handleMigratingBackend) + ipcRenderer.on('migration-failed', this.handleMigrationFailure) + ipcRenderer.on("starting-backend", this.handleStartingBackend) + + ipcRenderer.send("app-fully-loaded") setInterval(() => { if (this.props.apiConnection !== 1) { @@ -138,6 +169,10 @@ class AppComponent extends React.Component { ipcRenderer.removeListener('history-push', this.handlePush) ipcRenderer.removeListener('set-debug-log-path', this.handleSetDebugLogPath) ipcRenderer.removeListener('reload', this.handleReload) + ipcRenderer.removeListener('incompatible-backend', this.handleIncompatibleBackend) + ipcRenderer.removeListener('migrating-backend', this.handleMigratingBackend) + ipcRenderer.removeListener('migration-failed', this.handleMigrationFailure) + ipcRenderer.removeListener("starting-backend", this.handleStartingBackend) } componentDidUpdate (prevProps: AppProps) { @@ -151,20 +186,52 @@ class AppComponent extends React.Component { } render () { - const { apiConnection, modal, loading } = this.props + const { apiConnection, modal, loading, bootupComponent } = this.props if (loading) { return ( - - - + <> + + + + + + + + + + + + + ) } @@ -228,6 +295,7 @@ export default connectComponentToProps( selections: selectSelections(state), apiConnection, modal: selectModal(state), + bootupComponent: selectBootupComponent(state), ...ownProps } }, @@ -236,6 +304,7 @@ export default connectComponentToProps( push, setModal, bootstrap, + setBootupComponent, pingApi } ) diff --git a/app/main.development.js b/app/main.development.js index 81e663cb..23b0ce96 100644 --- a/app/main.development.js +++ b/app/main.development.js @@ -102,8 +102,6 @@ app.on('ready', () => log.info('main process ready') autoUpdater.logger = log autoUpdater.checkForUpdatesAndNotify() - backendProcess = new BackendProcess() - backendProcess.maybeStartup() mainWindow = new BrowserWindow({ show: false, @@ -553,5 +551,49 @@ app.on('ready', () => quitting = true }) + ipcMain.on('app-fully-loaded', () => { + log.info("starting backend process") + backendProcess = new BackendProcess() + backendProcess.checkNoActiveBackendProcess() + .then(backendProcess.checkBackendCompatibility) + .then(backendProcess.checkNeedsMigration) + .then((needsMigration) => { + if (needsMigration) { + log.info("migrating backend") + // this doesn't trigger migrations, which happens automatically + // when we run `launchProcess`, but instead alerts the user + // that we are running a migration and it might take a moment + mainWindow.webContents.send("migrating-backend") + } + }) + .then(backendProcess.launchProcess) + .catch(err => { + switch (err.message) { + case "backend-already-running": + log.info("a qri backend is already running at port 2503") + mainWindow.webContents.send("backend-already-running") + break + case "incompatible-backend": + log.info("qri backend is incompatible") + mainWindow.webContents.send("incompatible-backend", backendProcess.backendVer) + break + case "migration-failed": + log.debug("migration-failed") + mainWindow.webContents.send("migration-failed") + break + case "error-launching-backend": + log.debug("error-launching-backend") + mainWindow.webContents.send("error-launching-backend") + break + default: + log.error(err.message) + } + // if we error here, we should close the process + log.info("closing backend process") + backendProcess.close() + }) + }) + log.info('app launched') - })) + }) + ) diff --git a/app/models/store.ts b/app/models/store.ts index eadcb988..98cb5731 100644 --- a/app/models/store.ts +++ b/app/models/store.ts @@ -75,8 +75,11 @@ export interface UI { detailsBar: Details importFileName: string importFileSize: number + bootupComponent: BootupComponentType } +export type BootupComponentType = 'loading' | 'migrating' | 'migrationFailure' | string + export type SelectedComponent = 'commit' | 'readme' | 'meta' | 'body' | 'structure' | 'transform' | '' // currently selected dataset, tab, dataset component, commit, etc diff --git a/app/reducers/ui.ts b/app/reducers/ui.ts index 83f49cbc..bb935216 100644 --- a/app/reducers/ui.ts +++ b/app/reducers/ui.ts @@ -24,6 +24,7 @@ export const UI_SET_DATASET_DIR_PATH = 'UI_SET_DATASET_DIR_PATH' export const UI_SET_EXPORT_PATH = 'UI_SET_EXPORT_PATH' export const UI_SET_DETAILS_BAR = 'UI_SET_DETAILS_BAR' export const UI_SET_IMPORT_FILE_DETAILS = 'UI_SET_IMPORT_FILE_DETAILS' +export const UI_SET_BOOTUP_COMPONENT = 'UI_SET_BOOTUP_COMPONENT' export const UNAUTHORIZED = 'UNAUTHORIZED' @@ -62,7 +63,8 @@ const initialState = { modal: { type: ModalType.NoModal }, detailsBar: { type: DetailsType.NoDetails }, importFileName: '', - importFileSize: 0 + importFileSize: 0, + bootupComponent: 'loading' } // send an event to electron to block menus on first load @@ -206,6 +208,12 @@ export default (state = initialState, action: AnyAction) => { importFileName: fileName } + case UI_SET_BOOTUP_COMPONENT: + const { component } = action + return { + ...state, + bootupComponent: component + } default: return state } diff --git a/app/selections.ts b/app/selections.ts index f8e73bec..40d20db8 100644 --- a/app/selections.ts +++ b/app/selections.ts @@ -10,7 +10,8 @@ import Store, { Selections, Toast, ApiConnection, - StatusInfo + StatusInfo, + BootupComponentType } from './models/store' import { Details, DetailsType } from "./models/details" import { datasetToVersionInfo } from "./actions/mappingFuncs" @@ -309,6 +310,9 @@ export function selectToast (state: Store): Toast { return state.ui.toast } +export function selectBootupComponent (state: Store): BootupComponentType { + return state.ui.bootupComponent +} /** * * WORKINGDATASET STATE TREE