Skip to content

Commit 4c8d3cb

Browse files
committed
new cli features: oidc-agent auth, 'info' command
1 parent 2019d61 commit 4c8d3cb

File tree

6 files changed

+168
-29
lines changed

6 files changed

+168
-29
lines changed

CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ Changelog for PyUNICORE
33

44
Issue tracker: https://github.com/HumanBrainProject/pyunicore
55

6+
Version 1.3.0 (mmm dd, 2025)
7+
----------------------------
8+
- new feature: CLI: implemented more authentication options
9+
(oidc-agent, anonymous)
10+
- new feature: CLI: 'info' command
11+
612
Version 1.2.0 (Nov 08, 2024)
713
----------------------------
814

docs/source/uftp.rst

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,47 @@ Here is a basic example using username/password.
3939
The object returned by `connect()` is an `ftplib` `FTP` object.
4040

4141

42+
Mounting remote filesystems via UFTP and FUSE
43+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
44+
45+
PyUNICORE contains a FUSE driver based on `fusepy <https://pypi.org/project/fusepy>`_,
46+
allowing you to mount a remote filesystem via UFTP. Mounting is a two step process,
47+
48+
* authenticate to an Auth server, giving you the UFTPD host/port and one-time password
49+
* run the FUSE driver
50+
51+
The following code example gives you the basic idea:
52+
53+
.. code:: python
54+
55+
import pyunicore.client as uc_client
56+
import pyunicore.credentials as uc_credentials
57+
import pyunicore.uftp as uc_uftp
58+
import pyunicore.uftpfuse as uc_fuse
59+
60+
_auth = "https://localhost:9000/rest/auth/TEST"
61+
_base_dir = "/opt/shared-data"
62+
_local_mount_dir = "/tmp/mount"
63+
64+
# authenticate
65+
cred = uc_credentials.UsernamePassword("demouser", "test123")
66+
_host, _port, _password = uc_uftp.UFTP().authenticate(cred, _auth, _base_dir)
67+
68+
# run the fuse driver
69+
fuse = uc_fuse.FUSE(
70+
uc_fuse.UFTPDriver(_host, _port, _password), _local_mount_dir, foreground=False, nothreads=True)
71+
4272
4373
Using UFTP for PyFilesystem
4474
~~~~~~~~~~~~~~~~~~~~~~~~~~~
4575

46-
You can create a `PyFilesystem <https://github.com/PyFilesystem/pyfilesystem2>`_
47-
`FS` object either directly in code, or implicitely via a URL.
76+
`PyFilesystem <https://github.com/PyFilesystem/pyfilesystem2>`_ is a virtual filesystem
77+
for Python supporting a variety of protocols.
78+
79+
PyUNICORE contains two "drivers" that leverage UFTP.
4880

81+
The first one accesses data directly via UFTP. You can create a PyFilesystem `FS`
82+
object either directly in code, or implicitely via a URL.
4983
The convenient way is via URL:
5084

5185
.. code:: python
@@ -70,34 +104,18 @@ The FS driver supports three types of authentication
70104
Specify the `password` if needed to load the private key
71105
* Bearer token - give the token value via the `token` parameter
72106

107+
The `base-directory` parameter denotes the remote directory that is to be accessed.
73108

109+
The second driver mounts the remote filesystem via FUSE, and then accesses
110+
data "locally". The URL format is
74111

75-
Mounting remote filesystems via UFTP
76-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
77-
78-
PyUNICORE contains a FUSE driver based on `fusepy <https://pypi.org/project/fusepy>`_,
79-
allowing you to mount a remote filesystem via UFTP. Mounting is a two step process,
80-
81-
* authenticate to an Auth server, giving you the UFTPD host/port and one-time password
82-
* run the FUSE driver
83-
84-
The following code example gives you the basic idea:
85-
86-
.. code:: python
112+
.. code:: console
87113
88-
import pyunicore.client as uc_client
89-
import pyunicore.credentials as uc_credentials
90-
import pyunicore.uftp as uc_uftp
91-
import pyunicore.uftpfuse as uc_fuse
114+
uftpmount://[username]:[password]@[auth-server-url]:[base-directory==mount-directory]?[token=...][identity=...]
92115
93-
_auth = "https://localhost:9000/rest/auth/TEST"
94-
_base_dir = "/opt/shared-data"
95-
_local_mount_dir = "/tmp/mount"
116+
with the same authentication options as before.
96117

97-
# authenticate
98-
cred = uc_credentials.UsernamePassword("demouser", "test123")
99-
_host, _port, _password = uc_uftp.UFTP().authenticate(cred, _auth, _base_dir)
118+
The mount directory is given
100119

101-
# run the fuse driver
102-
fuse = uc_fuse.FUSE(
103-
uc_fuse.UFTPDriver(_host, _port, _password), _local_mount_dir, foreground=False, nothreads=True)
120+
The `base-directory==mount-directory` parameter denotes the remote directory that is to be accessed,
121+
as well as the local directory where it should be mounted.

pyunicore/cli/base.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,15 @@ def get_group(self):
8383

8484
def create_credential(self):
8585
auth_method = self.config.get("authentication-method", "USERNAME").upper()
86-
if "USERNAME" == auth_method:
86+
if "OIDC-AGENT" == auth_method:
87+
account_name = self.config.get("oidc-agent.account")
88+
self.credential = pyunicore.credentials.OIDCAgentToken(account_name)
89+
elif "USERNAME" == auth_method:
8790
username = self.config["username"]
8891
password = self._get_password()
89-
self.credential = pyunicore.credentials.create_credential(username, password)
92+
self.credential = pyunicore.credentials.create_credential(username, password)
93+
elif "ANONYMOUS" == auth_method:
94+
self.credential = pyunicore.credentials.Anonymous()
9095

9196
def _get_password(self, key="password") -> str:
9297
password = self.config.get(key)

pyunicore/cli/info.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from __future__ import annotations
2+
3+
from pyunicore.cli.base import Base
4+
from pyunicore.client import Resource
5+
6+
7+
class Info(Base):
8+
def add_command_args(self):
9+
self.parser.prog = "unicore system-info"
10+
self.parser.description = self.get_synopsis()
11+
self.parser.add_argument("URL", help="Endpoint URL(s)", nargs="*")
12+
self.parser.add_argument(
13+
"-p",
14+
"--pattern",
15+
required=False,
16+
type=str,
17+
help="Only show info for endpoints matching the given regexp",
18+
)
19+
self.parser.add_argument(
20+
"-l", "--long", required=False, action="store_true", help="Show detailed info"
21+
)
22+
23+
def get_synopsis(self):
24+
return """Show information about endpoint(s). If no explicit endpoints are given,
25+
the endpoints in the registry are used. The optional pattern allows to limit which
26+
endpoints are listed."""
27+
28+
def get_description(self):
29+
return "show info on available services"
30+
31+
def get_group(self):
32+
return "Utilities"
33+
34+
def run(self, args):
35+
super().setup(args)
36+
endpoints = self.registry.site_urls.values()
37+
38+
if self.args.URL:
39+
endpoints = self.args.URL
40+
41+
for url in endpoints:
42+
c = Resource(self.credential, resource_url=url)
43+
self.show_endpoint_details(c)
44+
45+
def show_endpoint_details(self, ep: Resource):
46+
print(ep.resource_url)
47+
if "/rest/core" in ep.resource_url:
48+
self._show_details_core(ep)
49+
else:
50+
print(" * no further details available.")
51+
52+
def _show_details_core(self, ep: Resource):
53+
props = ep.properties
54+
print(f" * server v{props['server']['version']}")
55+
xlogin = props["client"]["xlogin"]
56+
role = props["client"]["role"]["selected"]
57+
uid = xlogin.get("UID", "n/a")
58+
print(f" * authenticated as: '{props['client']['dn']}' role='{role}' uid='{uid}'")
59+
grps = xlogin.get("availableGroups", [])
60+
uids = xlogin.get("availableUIDs", [])
61+
if len(uids) > 0:
62+
print(f" * available UIDs: {uids}")
63+
if len(grps) > 0:
64+
print(f" * available groups: {grps}")
65+
roles = props["client"]["role"].get("availableRoles", [])
66+
if len(roles) > 0:
67+
print(f" * available roles: {roles}")

pyunicore/cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55

66
import pyunicore.cli.base
77
import pyunicore.cli.exec
8+
import pyunicore.cli.info
89
import pyunicore.cli.io
910

1011
_commands = {
1112
"cancel-job": pyunicore.cli.exec.CancelJob,
1213
"cat": pyunicore.cli.io.Cat,
1314
"cp": pyunicore.cli.io.CP,
1415
"exec": pyunicore.cli.exec.Exec,
16+
"info": pyunicore.cli.info.Info,
1517
"issue-token": pyunicore.cli.base.IssueToken,
1618
"job-status": pyunicore.cli.exec.GetJobStatus,
1719
"list-jobs": pyunicore.cli.exec.ListJobs,

pyunicore/credentials.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@
1010
pass
1111

1212
import datetime
13+
import json
14+
import socket
1315
from abc import ABCMeta
1416
from abc import abstractmethod
1517
from base64 import b64encode
18+
from os import environ
1619
from os import getenv
1720
from os.path import isabs
1821

@@ -202,6 +205,44 @@ def get_auth_header(self):
202205
return "Bearer " + self.create_token()
203206

204207

208+
class OIDCAgentToken(OIDCToken):
209+
"""
210+
Produces a header value "Bearer <auth_token>"
211+
212+
Args:
213+
token: the value of the auth token
214+
refresh_handler: optional refresh handler that provides a get_token() method which
215+
will be invoked to refresh the bearer token
216+
"""
217+
218+
def __init__(self, account_name):
219+
super().__init__(token=None, refresh_handler=None)
220+
self.account = account_name
221+
self.token = self.get_token()
222+
223+
def get_token(self) -> str:
224+
params = {}
225+
params["account"] = self.account
226+
params["request"] = "access_token"
227+
# TODO: params["scope"] = ...
228+
try:
229+
socket_path = environ.get("OIDC_SOCK")
230+
except KeyError:
231+
raise OSError("OIDC Agent not running (environment variable OIDC_SOCK not found)")
232+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
233+
sock.connect(socket_path)
234+
sock.sendall(json.dumps(params).encode("utf-8"))
235+
res = b""
236+
while True:
237+
part = sock.recv(4096)
238+
res += part
239+
if len(part) < 4096:
240+
break
241+
reply = json.loads(res.decode("utf-8"))
242+
if "success" == reply.get("status", None):
243+
return reply["access_token"]
244+
245+
205246
def create_credential(username=None, password=None, token=None, identity=None):
206247
"""Helper to create the most common types of credentials
207248

0 commit comments

Comments
 (0)