Skip to content

Commit 2758b55

Browse files
Merge pull request #21646 from bernt-matthias/ssh-fs-improvements
Improvements for ssh file sources
2 parents bcd1366 + b8a99ad commit 2758b55

19 files changed

Lines changed: 640 additions & 35 deletions

File tree

client/src/api/fileSources.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ export const templateTypes: FileSourceTypesDetail = {
8787
icon: faNetworkWired,
8888
message: "This is a file repository plugin that connects with an OMERO server.",
8989
},
90+
ssh: {
91+
icon: faNetworkWired,
92+
message: "This is a file repository plugin that connects with a remote server over SSH.",
93+
},
9094
};
9195

9296
export const FileSourcesValidFilters = {

client/src/api/schema/schema.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12847,7 +12847,8 @@ export interface components {
1284712847
| "dataverse"
1284812848
| "huggingface"
1284912849
| "iiif"
12850-
| "omero";
12850+
| "omero"
12851+
| "ssh";
1285112852
/** Variables */
1285212853
variables?:
1285312854
| (
@@ -23022,6 +23023,8 @@ export interface components {
2302223023
help?: string | null;
2302323024
/** Label */
2302423025
label?: string | null;
23026+
/** Multiline */
23027+
multiline?: boolean | null;
2302523028
/** Name */
2302623029
name: string;
2302723030
/** Optional */
@@ -23035,6 +23038,8 @@ export interface components {
2303523038
help?: string | null;
2303623039
/** Label */
2303723040
label?: string | null;
23041+
/** Multiline */
23042+
multiline?: boolean | null;
2303823043
/** Name */
2303923044
name: string;
2304023045
/** Optional */
@@ -23061,6 +23066,8 @@ export interface components {
2306123066
help?: string | null;
2306223067
/** Label */
2306323068
label?: string | null;
23069+
/** Multiline */
23070+
multiline?: boolean | null;
2306423071
/** Name */
2306523072
name: string;
2306623073
/** Optional */
@@ -23087,6 +23094,8 @@ export interface components {
2308723094
help?: string | null;
2308823095
/** Label */
2308923096
label?: string | null;
23097+
/** Multiline */
23098+
multiline?: boolean | null;
2309023099
/** Name */
2309123100
name: string;
2309223101
/** Optional */
@@ -23113,6 +23122,8 @@ export interface components {
2311323122
help?: string | null;
2311423123
/** Label */
2311523124
label?: string | null;
23125+
/** Multiline */
23126+
multiline?: boolean | null;
2311623127
/** Name */
2311723128
name: string;
2311823129
/** Optional */
@@ -24487,7 +24498,8 @@ export interface components {
2448724498
| "dataverse"
2448824499
| "huggingface"
2448924500
| "iiif"
24490-
| "omero";
24501+
| "omero"
24502+
| "ssh";
2449124503
/** Uri Root */
2449224504
uri_root: string;
2449324505
/**

client/src/components/ConfigTemplates/EditSecretsForm.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async function update(secretName: string, secretValue: string) {
2929
:name="secret.name"
3030
:help="secret.help || ''"
3131
:is-set="true"
32+
:multiline="secret.multiline || false"
3233
@update="update">
3334
</VaultSecret>
3435
</div>

client/src/components/ConfigTemplates/VaultSecret.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,18 @@ describe("VaultSecret", () => {
2323
// verify markdown converted
2424
expect(helpWrapper.html()).toEqual("<p>here is some good <em>help</em></p>");
2525
});
26+
27+
it("should render a textarea editor for multiline secrets", async () => {
28+
const wrapper = shallowMount(VaultSecret as object, {
29+
propsData: {
30+
name: "secret name",
31+
label: "Label Secret",
32+
help: "pem help",
33+
isSet: true,
34+
multiline: true,
35+
},
36+
localVue,
37+
});
38+
expect(wrapper.html()).toContain("bformtextarea-stub");
39+
});
2640
});

client/src/components/ConfigTemplates/VaultSecret.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import { faPen } from "@fortawesome/free-solid-svg-icons";
33
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
4-
import { BButton, BFormInput, BInputGroup, BInputGroupAppend } from "bootstrap-vue";
4+
import { BButton, BFormInput, BFormTextarea, BInputGroup, BInputGroupAppend } from "bootstrap-vue";
55
import { computed, ref } from "vue";
66
77
import { markup } from "@/components/ObjectStore/configurationMarkdown";
@@ -11,6 +11,7 @@ interface Props {
1111
label: string;
1212
help: string;
1313
isSet: boolean;
14+
multiline?: boolean;
1415
}
1516
const props = defineProps<Props>();
1617
@@ -57,7 +58,8 @@ async function onOk() {
5758
</div>
5859
<b-modal ref="edit-modal" v-model="showEdit" :title="editTitle" ok-title="Update" @ok="onOk">
5960
<div>
60-
<BFormInput v-model="secretValue" type="password" />
61+
<BFormTextarea v-if="multiline" v-model="secretValue" rows="8" no-resize />
62+
<BFormInput v-else v-model="secretValue" type="password" />
6163
<!-- eslint-disable-next-line vue/no-v-html -->
6264
<span class="ui-form-info form-text text-muted" v-html="helpHtml" />
6365
</div>

client/src/components/ConfigTemplates/formUtil.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ describe("formUtils", () => {
122122
const formEntry = templateVariableFormEntry(requiredIntVar, undefined);
123123
expect(formEntry.value).toBe(0);
124124
});
125+
it("should render multiline string types as Galaxy textarea inputs", () => {
126+
const multilineVariable: TemplateVariable = {
127+
name: "private_key",
128+
type: "string",
129+
multiline: true,
130+
};
131+
const formEntry = templateVariableFormEntry(multilineVariable, "line1\nline2");
132+
expect(formEntry.type).toBe("text");
133+
expect(formEntry.area).toBe(true);
134+
expect(formEntry.value).toBe("line1\nline2");
135+
});
125136
});
126137

127138
describe("templateVariableFormEntry optional field", () => {
@@ -184,6 +195,17 @@ describe("formUtils", () => {
184195
const formEntry = templateSecretFormEntry(secretWithoutOptional);
185196
expect(formEntry.optional).toBe(false);
186197
});
198+
199+
it("should render multiline secrets as textarea inputs", () => {
200+
const multilineSecret: TemplateSecret = {
201+
name: "private_key",
202+
help: "PEM content",
203+
multiline: true,
204+
};
205+
const formEntry = templateSecretFormEntry(multilineSecret);
206+
expect(formEntry.type).toBe("text");
207+
expect(formEntry.area).toBe(true);
208+
});
187209
});
188210

189211
describe("formDataTypedGet", () => {

client/src/components/ConfigTemplates/formUtil.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface FormEntry {
1818
optional?: boolean;
1919
help?: string | null;
2020
type: string;
21+
area?: boolean;
2122
value?: any;
2223
validators?: TemplateVariableValidator[];
2324
}
@@ -37,7 +38,8 @@ export function metadataFormEntryDescription(what: string): FormEntry {
3738
name: "_meta_description",
3839
label: "Description",
3940
optional: true,
40-
type: "textarea",
41+
type: "text",
42+
area: true,
4143
help: `Provide some notes to yourself about this ${what} - perhaps to remind you how it is configured, where it stores the data, etc..`,
4244
};
4345
}
@@ -54,6 +56,7 @@ export function templateVariableFormEntry(variable: TemplateVariable, variableVa
5456
const defaultValue = variable.default ?? "";
5557
return {
5658
type: "text",
59+
area: variable.multiline || false,
5760
value: variableValue == undefined ? defaultValue : variableValue,
5861
...common_fields,
5962
};
@@ -87,7 +90,8 @@ export function templateSecretFormEntry(secret: TemplateSecret): FormEntry {
8790
return {
8891
name: secret.name,
8992
label: secret.label ?? secret.name,
90-
type: "password",
93+
type: secret.multiline ? "text" : "password",
94+
area: secret.multiline || false,
9195
help: markup(secret.help || "", true),
9296
value: "",
9397
optional: secret.optional || false,

lib/galaxy/files/sources/ssh.py

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,102 @@
1-
try:
2-
from fs.sshfs.sshfs import SSHFS
3-
except ImportError:
4-
SSHFS = None
5-
1+
from io import StringIO
62
from typing import (
73
Optional,
4+
TYPE_CHECKING,
85
Union,
96
)
107

11-
from galaxy.files.models import (
12-
BaseFileSourceConfiguration,
13-
BaseFileSourceTemplateConfiguration,
14-
FilesSourceRuntimeContext,
8+
try:
9+
from fsspec.implementations.sftp import SFTPFileSystem
10+
from paramiko.ecdsakey import ECDSAKey
11+
from paramiko.ed25519key import Ed25519Key
12+
from paramiko.rsakey import RSAKey
13+
except ImportError:
14+
SFTPFileSystem = None
15+
if TYPE_CHECKING:
16+
from paramiko.ecdsakey import ECDSAKey
17+
from paramiko.ed25519key import Ed25519Key
18+
from paramiko.rsakey import RSAKey
19+
20+
from galaxy.exceptions import AuthenticationFailed
21+
from galaxy.files.models import FilesSourceRuntimeContext
22+
from galaxy.files.sources._fsspec import (
23+
CacheOptionsDictType,
24+
FsspecBaseFileSourceConfiguration,
25+
FsspecBaseFileSourceTemplateConfiguration,
26+
FsspecFilesSource,
1527
)
1628
from galaxy.util.config_templates import TemplateExpansion
17-
from ._pyfilesystem2 import PyFilesystem2FilesSource
1829

1930

20-
class SshFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration):
31+
def _parse_private_key(private_key: str, password: Optional[str]):
32+
# Paramiko cannot autodetect the key type, so try the supported key classes.
33+
for pkey_class in (RSAKey, ECDSAKey, Ed25519Key):
34+
try:
35+
with StringIO(private_key) as pkey_file:
36+
return pkey_class.from_private_key(pkey_file, password=password)
37+
except Exception:
38+
continue
39+
40+
return None
41+
42+
43+
class SshFileSourceTemplateConfiguration(FsspecBaseFileSourceTemplateConfiguration):
2144
host: Union[str, TemplateExpansion]
2245
user: Optional[Union[str, TemplateExpansion]] = None
2346
passwd: Optional[Union[str, TemplateExpansion]] = None
2447
pkey: Optional[Union[str, TemplateExpansion]] = None
2548
timeout: Union[int, TemplateExpansion] = 10
2649
port: Union[int, TemplateExpansion] = 22
2750
compress: Union[bool, TemplateExpansion] = False
28-
config_path: Union[str, TemplateExpansion] = "~/.ssh/config"
2951
path: Union[str, TemplateExpansion]
3052

3153

32-
class SshFileSourceConfiguration(BaseFileSourceConfiguration):
54+
class SshFileSourceConfiguration(FsspecBaseFileSourceConfiguration):
3355
host: str
3456
user: Optional[str] = None
3557
passwd: Optional[str] = None
3658
pkey: Optional[str] = None
3759
timeout: int = 10
3860
port: int = 22
3961
compress: bool = False
40-
config_path: str = "~/.ssh/config"
4162
path: str
4263

4364

44-
class SshFilesSource(PyFilesystem2FilesSource[SshFileSourceTemplateConfiguration, SshFileSourceConfiguration]):
65+
class SshFilesSource(FsspecFilesSource[SshFileSourceTemplateConfiguration, SshFileSourceConfiguration]):
4566
plugin_type = "ssh"
46-
required_module = SSHFS
47-
required_package = "fs.sshfs"
67+
required_module = SFTPFileSystem
68+
required_package = "fsspec"
4869

4970
template_config_class = SshFileSourceTemplateConfiguration
5071
resolved_config_class = SshFileSourceConfiguration
5172

52-
def _open_fs(self, context: FilesSourceRuntimeContext[SshFileSourceConfiguration]):
53-
if SSHFS is None:
73+
def _open_fs(
74+
self,
75+
context: FilesSourceRuntimeContext[SshFileSourceConfiguration],
76+
cache_options: CacheOptionsDictType, # Ignored because fsspec's SFTPFileSystem does not support caching options.
77+
):
78+
if SFTPFileSystem is None:
5479
raise self.required_package_exception
5580

5681
config = context.config
57-
handle = SSHFS(
82+
pkey = None
83+
password = config.passwd
84+
if config.pkey:
85+
pkey = _parse_private_key(config.pkey, config.passwd)
86+
if pkey is None:
87+
raise AuthenticationFailed("Invalid or unsupported SSH private key provided.")
88+
password = None
89+
90+
fs = SFTPFileSystem(
5891
host=config.host,
59-
user=config.user,
60-
passwd=config.passwd,
61-
pkey=config.pkey,
92+
username=config.user,
93+
password=password,
94+
pkey=pkey,
6295
port=config.port,
6396
timeout=config.timeout,
6497
compress=config.compress,
65-
config_path=config.config_path,
6698
)
67-
return handle
99+
return fs
68100

69101
def _to_filesystem_path(self, path: str, config: SshFileSourceConfiguration) -> str:
70102
base = config.path.rstrip("/")
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
- id: ssh
2+
version: 0
3+
name: SSH Server
4+
description: |
5+
Setup connections to SSH servers to download and upload files.
6+
configuration:
7+
type: ssh
8+
host: "{{ variables.host }}"
9+
user: "{{ variables.user }}"
10+
passwd: "{{ secrets.password }}"
11+
pkey: "{{ secrets.pkey }}"
12+
port: "{{ variables.port }}"
13+
path: "{{ variables.path }}"
14+
writable: "{{ variables.writable }}"
15+
variables:
16+
host:
17+
label: SSH Host
18+
type: string
19+
help: Host of SSH Server to connect to.
20+
user:
21+
label: SSH User
22+
type: string
23+
optional: true
24+
help: |
25+
Username to connect with.
26+
path:
27+
label: Path
28+
type: string
29+
help: |
30+
A path that is readable / writable by the user
31+
writable:
32+
label: Writable?
33+
type: boolean
34+
optional: true
35+
help: Allow Galaxy to write data to this file source. Requires that user is allowed to write to this path on the host.
36+
port:
37+
label: SSH Port
38+
type: integer
39+
optional: true
40+
help: Port used to connect to the SSH server.
41+
secrets:
42+
password:
43+
label: SSH Password
44+
optional: true
45+
help: |
46+
Password to connect to SSH server with.
47+
pkey:
48+
label: SSH private key
49+
optional: true
50+
multiline: true
51+
help: |
52+
Private key to be used. For encrypted keys the given password is used to decrypt.

0 commit comments

Comments
 (0)