Keep multiple installed versions of each plugin

Summary:
This diff changes directory structure for installed plugins to allow installation of multiple versions simultaneously, e.g. to to allow downloading new plugin version while user is still using the previous one, and to have possibility of fast rollback to the previous installed if necessary. The new folder for installed plugins is located in `~/.flipper/installed-plugins` and has the following structure:

  flipper-plugin-reactotron
    1.0.0
      ...
      package.json
    1.0.1
      ...
      package.json
  flipper-plugin-network
    0.67.1
      ...
      package.json
    0.67.2
      ...
      package.json

The tricky part here is that we also need to migrate already installed plugins from the old folder `~/.flipper/thirdparty` to the new folder and maintain the new structure for them.

Another tricky part is that we need to periodically cleanup old versions. For now we will just keep 2 versions of each plugin. Cleanup is performed in background right after Flipper startup.

Reviewed By: mweststrate

Differential Revision: D25393474

fbshipit-source-id: 26617ac26114148f797cc3d6765a42242edc205e
This commit is contained in:
Anton Nikolaev
2020-12-15 09:28:58 -08:00
committed by Facebook GitHub Bot
parent 9c5f59e109
commit 02d695cb28
15 changed files with 194 additions and 398 deletions

View File

@@ -18,14 +18,16 @@ import tmp from 'tmp';
import PluginDetails from './PluginDetails';
import {getPluginDetailsFromDir} from './getPluginDetails';
import {
getPluginInstallationDir,
getPluginPendingInstallationDir,
getPluginPendingInstallationsDir,
pluginInstallationDir,
pluginPendingInstallationDir,
getPluginVersionInstallationDir,
getPluginDirNameFromPackageName,
getPluginInstallationDir,
pluginInstallationDir,
legacyPluginInstallationDir,
} from './pluginPaths';
import pfilter from 'p-filter';
import pmap from 'p-map';
import semver from 'semver';
import {notNull} from './typeUtils';
const getTmpDir = promisify(tmp.dir) as () => Promise<string>;
@@ -39,8 +41,7 @@ async function installPluginFromTempDir(
const pluginDetails = await getPluginDetailsFromDir(sourceDir);
const {name, version} = pluginDetails;
const backupDir = path.join(await getTmpDir(), `${name}-${version}`);
const installationsDir = getPluginPendingInstallationsDir(name);
const destinationDir = getPluginPendingInstallationDir(name, version);
const destinationDir = getPluginVersionInstallationDir(name, version);
if (pluginDetails.specVersion == 1) {
throw new Error(
@@ -53,17 +54,7 @@ async function installPluginFromTempDir(
if (await fs.pathExists(destinationDir)) {
await fs.move(destinationDir, backupDir, {overwrite: true});
}
await fs.move(sourceDir, destinationDir);
// Cleaning up all the previously downloaded packages, because we've got the newest one.
const otherPackages = await fs.readdir(installationsDir);
for (const otherPackage of otherPackages) {
const otherPackageDir = path.join(installationsDir, otherPackage);
if (otherPackageDir !== destinationDir) {
await fs.remove(otherPackageDir);
}
}
} catch (err) {
// Restore previous version from backup if installation failed
await fs.remove(destinationDir);
@@ -95,21 +86,15 @@ async function getPluginRootDir(dir: string) {
export async function getInstalledPlugin(
name: string,
version: string,
): Promise<PluginDetails | null> {
const dir = getPluginInstallationDir(name);
const dir = getPluginVersionInstallationDir(name, version);
if (!(await fs.pathExists(dir))) {
return null;
}
return await getPluginDetailsFromDir(dir);
}
export async function isPluginPendingInstallation(
name: string,
version: string,
) {
return await fs.pathExists(getPluginPendingInstallationDir(name, version));
}
export async function installPluginFromNpm(name: string) {
const tmpDir = await getTmpDir();
try {
@@ -146,63 +131,105 @@ export async function installPluginFromFile(
}
export async function removePlugin(name: string): Promise<void> {
await Promise.all([
fs.remove(getPluginInstallationDir(name)),
fs.remove(getPluginPendingInstallationsDir(name)),
]);
await fs.remove(getPluginInstallationDir(name));
}
export async function finishPendingPluginInstallations() {
if (!(await fs.pathExists(pluginPendingInstallationDir))) {
return;
}
try {
await fs.ensureDir(pluginInstallationDir);
// create empty watchman config (required by metro's file watcher)
const watchmanConfigPath = path.join(
pluginInstallationDir,
'.watchmanconfig',
);
if (!(await fs.pathExists(watchmanConfigPath))) {
await fs.writeFile(watchmanConfigPath, '{}');
}
const pendingPlugins = await fs.readdir(pluginPendingInstallationDir);
for (const pendingPlugin of pendingPlugins) {
const pendingInstallationsDir = getPluginPendingInstallationsDir(
pendingPlugin,
export async function getInstalledPlugins(): Promise<PluginDetails[]> {
const versionDirs = await getInstalledPluginVersionDirs();
return pmap(
versionDirs
.filter(([_, versionDirs]) => versionDirs.length > 0)
.map(([_, versionDirs]) => versionDirs[0]),
(latestVersionDir) => getPluginDetailsFromDir(latestVersionDir),
);
}
export async function cleanupOldInstalledPluginVersions(
maxNumberOfVersionsToKeep: number,
): Promise<void> {
const versionDirs = await getInstalledPluginVersionDirs();
const versionDirsToDelete = versionDirs
.map(([_, versionDirs]) => versionDirs.slice(maxNumberOfVersionsToKeep))
.flat();
await pmap(versionDirsToDelete, (versionDirToDelete) =>
fs.remove(versionDirToDelete).catch(() => {}),
);
}
// Before that we installed all plugins to "thirdparty" folder and only kept
// a single version for each of them. Now we install plugins to "installed-plugins"
// folder and keep multiple versions. This function checks if the legacy folder exists and
// moves all the plugins installed there to the new folder.
export async function moveInstalledPluginsFromLegacyDir() {
if (await fs.pathExists(legacyPluginInstallationDir)) {
await fs
.readdir(legacyPluginInstallationDir)
.then((dirs) =>
dirs.map((dir) => path.join(legacyPluginInstallationDir, dir)),
)
.then((dirs) =>
pfilter(dirs, (dir) =>
fs
.lstat(dir)
.then((lstat) => lstat.isDirectory())
.catch(() => Promise.resolve(false)),
),
)
.then((dirs) =>
pmap(dirs, (dir) => getPluginDetailsFromDir(dir).catch(() => null)),
)
.then((plugins) =>
pmap(plugins.filter(notNull), (plugin) =>
fs.move(
plugin.dir,
getPluginVersionInstallationDir(plugin.name, plugin.version),
{overwrite: true},
),
),
);
const pendingVersions = (
await fs.readdir(pendingInstallationsDir)
).sort((v1, v2) => semver.compare(v2, v1, true)); // sort versions in descending order
if (pendingVersions.length === 0) {
await fs.remove(pendingInstallationsDir);
continue;
}
const version = pendingVersions[0];
const pendingInstallation = path.join(pendingInstallationsDir, version);
const installationDir = getPluginInstallationDir(pendingPlugin);
const backupDir = path.join(await getTmpDir(), pendingPlugin);
try {
if (await fs.pathExists(installationDir)) {
await fs.move(installationDir, backupDir, {overwrite: true});
}
await fs.move(pendingInstallation, installationDir, {overwrite: true});
await fs.remove(pendingInstallationsDir);
} catch (err) {
console.error(
`Error while finishing pending installation for ${pendingPlugin}`,
err,
);
// in case of error, keep the previously installed version
await fs.remove(installationDir);
if (await fs.pathExists(backupDir)) {
await fs.move(backupDir, installationDir, {overwrite: true});
}
} finally {
await fs.remove(backupDir);
}
}
} catch (err) {
console.error('Error while finishing plugin pending installations', err);
await fs.remove(legacyPluginInstallationDir);
}
}
type InstalledPluginVersionDirs = [string, string[]][];
async function getInstalledPluginVersionDirs(): Promise<
InstalledPluginVersionDirs
> {
return await fs
.readdir(pluginInstallationDir)
.then((dirs) => dirs.map((dir) => path.join(pluginInstallationDir, dir)))
.then((dirs) =>
pfilter(dirs, (dir) =>
fs
.lstat(dir)
.then((lstat) => lstat.isDirectory())
.catch(() => false),
),
)
.then((dirs) =>
pmap(dirs, (dir) =>
fs
.readdir(dir)
.then((versionDirs) =>
versionDirs.sort((v1, v2) => semver.compare(v2, v1, true)),
)
.then((versionDirs) =>
versionDirs.map((versionDir) => path.join(dir, versionDir)),
)
.then((versionDirs) =>
pfilter(versionDirs, (versionDir) =>
fs
.lstat(versionDir)
.then((lstat) => lstat.isDirectory())
.catch(() => false),
),
),
).then((allDirs) =>
allDirs.reduce<InstalledPluginVersionDirs>((agg, versionDirs, i) => {
agg.push([dirs[i], versionDirs]);
return agg;
}, []),
),
);
}