Skip to content

Commit

Permalink
Pluggable URI handling across upload components.
Browse files Browse the repository at this point in the history
*Overview*

This work defines an interface for interacting with "filesystem"-like entities during "upload". In addition to being a pluggable framework adding important new capabilities to Galaxy, this is a generalization and formalization of existing file sources (e.g. the directories described by `library_import_dir`, `user_library_dir`, and `ftp_upload_dir`).

*Plugin Infrastructure*

This introduces a new plugin `FilesSource` to represent sources of directories and files during "upload". A `FilesSource` plugin should be able to index directories and download (called 'realize' to be generic) files to local posix directories. Indexing is used by the remote_files API to provide the client with hierarchies to navigate and to build URIs for the files. The 'realize' operation is used by the 'upload1' and '__DATA_FETCH__' and tools during upload to bring the files into Galaxy as datasets.

An instance of the `ConfiguredFileSources` class is responsible for managing individual instances of `FilesSource` plugins. It has methods to map URIs to the appropriate plugin instance.

The `ConfiguredFileSources` class tracks the loaded plugins and reuses the go to `galaxy.util.plugin_config` module for loading YAML (or XML) definitions of plugins (the same dependency resolvers, job metrics, auth backends, etc. do). A `ConfiguredFileSources` object can serialize itself to a file and re-materialize it during job execution to allow using this abstraction during uploads.

When operating within the Galaxy app, the `ConfiguredFileSources` uses an adapter pattern to parse user-level information from Galaxy's `trans` object. During serialization, the `ConfiguredFileSources` object is expected to encode all the required information about the user that is needed into the output JSON description of the file sources. This is because the web transaction won't be available remotely during the upload job. These objects working in such different ways between the Galaxy process and in the remote job is mildly jarring - so unit tests have been written to ensure this all functions properly.

*Plugin Implementations*

The `FilesSource` interface has a helper implementation base class `BaseFilesSource` that provides some assistance for plugin development. Additionally, the base class `PyFilesystem2FilesSource` extends `BaseFilesSource` but assumes a PyFilesystem2 implementation exists to target the file source of interest - so the plugin author need only provide a PyFilesystem `FS` object describing the target. This commit includes three concrete implementations - posix, webdav, and dropbox. `posix` extends `BaseFilesSource` while the others are light-weight extensions of `PyFilesystem2FilesSource`.

**posix**

While one could imagine a very lightweight implementation based on `PyFilesystem2FilesSource` this fully worked through plugin is implemented directly to ensure we respect Galaxy's strong security checks on paths containing symlinks and preserve the semantics `user_library_import_symlink_allowlist`.

**webdav**

Galaxy tools for integrating OwnCloud exist - see https://github.com/shiltemann/Galaxy-Owncloud-Integration, part of the driver for this work was extending that idea to provide more integrated UX for uploading that data. So this work includes a WebDav plugin (and associated test cases) that could potentially target OwnCloud.

This plugin was a good exercise in flushing and testing the PyFilesystem2 interface but the PyFilesystem2 WebDAV implementation seems a bit fragile... we might want to replace it with more direct APIs but we can take a wait and see approach.

The config YAML for a webdav plugin that lets user's target their own OwnCloud servers configured via user preferences might look something like:

```
- type: webdav
  id: owncloud1
  label: OwnCloud
  doc: User-configured OwnCloud files
  url: ${user.preferences['owncloud|url']}
  login: ${user.preferences['owncloud|username']}
  password: ${user.preferences['webdav|password']}
```

The configuration would provide a user's OwnCloud files at `gxfiles://owncloud1/`.

If instead, a big centralized WebDav server is made available with public data for all users (mirroring use cases of `library_import_dir`) - a simpler configuration not requiring user preferences might be something like:

```
- type: webdav
  id: lab
  label: Lab WebDAV server
  doc: Our lab's research files managed at ourlab.org.
  url: http://ourlab.org:7083
  login: ${environ.get('WEBDAV_LOGIN')}
  password: ${environ.get('WEBDAV_PASSWORD')}
```

The configuration would provide a these WebDAV files at `gxfiles://lab/`.

These two examples demonstrate basic templating is allowed inside the YAML configuration. These are Cheetah templates exposing very specific views of the 'user', 'config', and the whole 'environ' available to the Galaxy server.

**dropbox**

The Dropbox PyFilesystem2 plugin is even easier to configure, all that is needed is a Dropbox access token (this can be configured from the settings menu and may be isolated to a specific app specific folder for added security on the user's part).

An example of such a plugin might be:

```
- type: dropbox
  id: dropbox1
  label: Dropbox Files
  doc: Your Dropbox files - configure an access token via the user preferences
  accessToken: ${user.preferences['dropbox|access_token']}
```

The configuration would provide a user's Dropbox files at `gxfiles://dropbox1/`.

**gxftp**

This is an automatically populated plugin (if `ftp_upload_dir` is configured in Galaxy) that provides the user's FTP files at `gxftp://`.

**gximport**

This is an automatically populated plugin (if `library_import_dir` is configured in Galaxy) that provides Galaxy's library import files at `gximport://`.

**gxuserimport**

This is an automatically populated plugin (if `user_library_import_dir` is configured in Galaxy) that provides the requesting user's Galaxy's user library import files at `gximportfiles://`.

*Why not a tool?*

One could imagine a tool - but the upload dialog has many advanced options for selecting how to ingest files (convert tabs and newlines, select format vs. detect, select dbkey, organize into collections, organize via rules, etc...). It would be next to impossible to provide all these same options via a normal tool and the user experience would be very different than using the upload components in Galaxy - which have been optimized and designed for this task.

That said - one future direction I would like to take this is to be able to mark plugins as writable and implement a new tool form input type "export_directory" or something like that. This could then be used to write data export tools. This could be used to write generalizations of the the cloud send tool.

*`ObjectStore` vs `FilesSource`*

ObjectStores provide datasets not files, the files are organized logically in a very flat way around a dataset. `FilesSource` s instead provide files and directories, not datasets. A `FilesSource` is meant to be browsed in hierarchical fashion - and also has no concept of extra files, etc..

*Future Work*

- This is hopefully going to serve as the basis of a first pass at Terra integration with Galaxy using the FISS lib. Having an implementation based on `PyFilesytem2` means we could potentially integrate support for S3, Basespace, Google Drive, OneDrive, etc..
- Tool form support for selecting files for import and directories for export.
- Allow writing collection archives, history export, etc.. to the `FilesSource` - this would really enhance the UI around getting big stuff out of Galaxy potentially I think.

Rebase into galaxy.files...
  • Loading branch information
jmchilton committed Jul 14, 2020
1 parent 593fe03 commit f93cc11
Show file tree
Hide file tree
Showing 53 changed files with 1,817 additions and 184 deletions.
163 changes: 163 additions & 0 deletions client/galaxy/scripts/components/FilesDialog/FilesDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<template>
<selection-dialog
:error-message="errorMessage"
:options-show="optionsShow"
:modal-show="modalShow"
:hide-modal="() => (modalShow = false)"
>
<template v-slot:search>
<data-dialog-search v-model="filter" />
</template>
<template v-slot:options>
<data-dialog-table
v-if="optionsShow"
:items="items"
:multiple="multiple"
:filter="filter"
:showDetails="showDetails"
:showTime="showTime"
@clicked="clicked"
@load="load"
/>
</template>
<template v-slot:buttons>
<b-btn size="sm" class="float-left" v-if="undoShow" @click="load()">
<div class="fa fa-caret-left mr-1" />
Back
</b-btn>
<b-btn
v-if="multiple"
size="sm"
class="float-right ml-1"
variant="primary"
@click="finalize"
:disabled="!hasValue"
>
Ok
</b-btn>
</template>
</selection-dialog>
</template>

<script>
import Vue from "vue";
import SelectionDialogMixin from "components/SelectionDialog/SelectionDialogMixin";
import { UrlTracker } from "components/DataDialog/utilities";
import { Services } from "./services";
import { Model } from "./model";
export default {
mixins: [SelectionDialogMixin],
props: {
multiple: {
type: Boolean,
default: false,
},
},
data() {
return {
errorMessage: null,
filter: null,
items: [],
modalShow: true,
optionsShow: false,
undoShow: false,
hasValue: false,
showTime: true,
showDetails: true,
};
},
created: function () {
this.services = new Services();
this.urlTracker = new UrlTracker("");
this.model = new Model({ multiple: this.multiple });
this.load();
},
methods: {
/** Add highlighting for record variations, i.e. datasets vs. libraries/collections **/
formatRows() {
for (const item of this.items) {
let _rowVariant = "active";
if (item.isLeaf) {
_rowVariant = this.model.exists(item.id) ? "success" : "default";
}
Vue.set(item, "_rowVariant", _rowVariant);
}
},
/** Collects selected datasets in value array **/
clicked: function (record) {
if (record.isLeaf) {
this.model.add(record);
this.hasValue = this.model.count() > 0;
if (this.multiple) {
this.formatRows();
} else {
this.finalize();
}
} else {
this.load(record.url);
}
},
/** Called when selection is complete, values are formatted and parsed to external callback **/
finalize: function () {
const results = this.model.finalize();
this.modalShow = false;
this.callback(results);
},
/** Performs server request to retrieve data records **/
load: function (url) {
url = this.urlTracker.getUrl(url);
this.filter = null;
this.optionsShow = false;
this.undoShow = !this.urlTracker.atRoot();
if (this.urlTracker.atRoot()) {
this.services
.getFileSources()
.then((items) => {
this.items = items.map((item) => {
return {
id: item.id,
label: item.label,
details: item.doc,
isLeaf: false,
url: item.uri_root,
labelTitle: item.uri_root,
};
});
this.formatRows();
this.optionsShow = true;
this.showTime = false;
this.showDetails = true;
})
.catch((errorMessage) => {
this.errorMessage = errorMessage;
});
} else {
this.services
.list(url)
.then((items) => {
this.items = items.map((item) => {
const itemClass = item.class;
return {
id: item.uri,
label: item.name,
time: item.ctime,
isLeaf: itemClass == "File",
size: item.size,
url: item.uri,
labelTitle: item.uri,
};
});
this.formatRows();
this.optionsShow = true;
this.showTime = true;
this.showDetails = false;
})
.catch((errorMessage) => {
this.errorMessage = errorMessage;
});
}
},
},
};
</script>
1 change: 1 addition & 0 deletions client/galaxy/scripts/components/FilesDialog/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as FilesDialog } from "./FilesDialog";
49 changes: 49 additions & 0 deletions client/galaxy/scripts/components/FilesDialog/model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Model to track selected URI for FilesDialog - mirroring DataDialog's model.
*/
export class Model {
constructor(options = {}) {
this.values = {};
this.multiple = options.multiple || false;
}

/** Adds a new record to the value stack **/
add(record) {
if (!this.multiple) {
this.values = {};
}
const key = record && record.id;
if (key) {
if (!this.values[key]) {
this.values[key] = record;
} else {
delete this.values[key];
}
} else {
throw "Invalid record with no <id>.";
}
}

/** Returns the number of added records **/
count() {
return Object.keys(this.values).length;
}

/** Returns true if a record is available for a given key **/
exists(key) {
return !!this.values[key];
}

/** Finalizes the results from added records **/
finalize() {
let results = [];
Object.values(this.values).forEach((v) => {
let value = v;
results.push(value);
});
if (results.length > 0 && !this.multiple) {
results = results[0];
}
return results;
}
}
31 changes: 31 additions & 0 deletions client/galaxy/scripts/components/FilesDialog/services.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import axios from "axios";
import { rethrowSimple } from "utils/simple-error";
import { getAppRoot } from "onload/loadConfig";

export class Services {
constructor(options = {}) {
this.root = options.root || getAppRoot();
}

async getFileSources() {
const url = `${this.root}api/remote_files/plugins`;
try {
const response = await axios.get(url);
const fileSources = response.data;
return fileSources;
} catch (e) {
rethrowSimple(e);
}
}

async list(uri) {
const url = `${this.root}api/remote_files?target=${uri}`;
try {
const response = await axios.get(url);
const fileSources = response.data;
return fileSources;
} catch (e) {
rethrowSimple(e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
>
<template v-slot:cell(label)="data">
<i v-if="data.item.isLeaf" :class="leafIcon" /> <i v-else class="fa fa-folder" />
{{ data.value ? data.value : "-" }}
<span :title="data.item.labelTitle">{{ data.value ? data.value : "-" }}</span>
</template>
<template v-slot:cell(details)="data">
{{ data.value ? data.value : "-" }}
Expand All @@ -39,6 +39,10 @@ import BootstrapVue from "bootstrap-vue";
Vue.use(BootstrapVue);
const LABEL_FIELD = { key: "label", sortable: true };
const DETAILS_FIELD = { key: "details", sortable: true };
const TIME_FIELD = { key: "time", sortable: true };
export default {
props: {
items: {
Expand All @@ -57,24 +61,18 @@ export default {
type: String,
default: "fa fa-file-o",
},
showDetails: {
type: Boolean,
default: true,
},
showTime: {
type: Boolean,
default: true,
},
},
data() {
return {
currentPage: 1,
fields: [
{
key: "label",
sortable: true,
},
{
key: "details",
sortable: true,
},
{
key: "time",
sortable: true,
},
],
nItems: 0,
perPage: 100,
};
Expand All @@ -87,6 +85,18 @@ export default {
},
},
},
computed: {
fields: function () {
const fields = [LABEL_FIELD];
if (this.showDetails) {
fields.push(DETAILS_FIELD);
}
if (this.showTime) {
fields.push(TIME_FIELD);
}
return fields;
},
},
methods: {
/** Resets pagination when a filter/search word is entered **/
filtered: function (items) {
Expand Down
6 changes: 3 additions & 3 deletions client/galaxy/scripts/components/Upload/Collection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,11 @@
ref="btnFtp"
class="ui-button-default"
id="btn-ftp"
@click="_eventFtp"
@click="_eventRemoteFiles"
:disabled="!enableSources"
v-if="ftpUploadSite"
v-if="remoteFiles"
>
<span class="fa fa-folder-open-o"></span>{{ btnFtpTitle }}
<span class="fa fa-folder-open-o"></span>{{ btnFilesTitle }}
</b-button>
<b-button
ref="btnLocal"
Expand Down
7 changes: 3 additions & 4 deletions client/galaxy/scripts/components/Upload/Default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@
ref="btnFtp"
class="ui-button-default"
id="btn-ftp"
@click="_eventFtp"
@click="_eventRemoteFiles"
:disabled="!enableSources"
v-if="ftpUploadSite"
v-if="remoteFiles"
>
<span class="fa fa-folder-open-o"></span>{{ btnFtpTitle }}
<span class="fa fa-folder-open-o"></span>{{ btnFilesTitle }}
</b-button>
<b-button
ref="btnLocal"
Expand Down Expand Up @@ -133,7 +133,6 @@ export default {
enableSources: false,
btnLocalTitle: _l("Choose local files"),
btnCreateTitle: _l("Paste/Fetch data"),
btnFtpTitle: _l("Choose FTP files"),
btnStartTitle: _l("Start"),
btnStopTitle: _l("Pause"),
btnResetTitle: _l("Reset"),
Expand Down
Loading

0 comments on commit f93cc11

Please sign in to comment.