plastron-stomp 4.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plastron_stomp-4.3.2/PKG-INFO +125 -0
- plastron_stomp-4.3.2/README.md +104 -0
- plastron_stomp-4.3.2/VERSION +1 -0
- plastron_stomp-4.3.2/pyproject.toml +46 -0
- plastron_stomp-4.3.2/setup.cfg +4 -0
- plastron_stomp-4.3.2/src/plastron/stomp/__init__.py +4 -0
- plastron_stomp-4.3.2/src/plastron/stomp/commands/__init__.py +42 -0
- plastron_stomp-4.3.2/src/plastron/stomp/commands/echo.py +26 -0
- plastron_stomp-4.3.2/src/plastron/stomp/commands/export.py +28 -0
- plastron_stomp-4.3.2/src/plastron/stomp/commands/importcommand.py +84 -0
- plastron_stomp-4.3.2/src/plastron/stomp/commands/publish.py +20 -0
- plastron_stomp-4.3.2/src/plastron/stomp/commands/unpublish.py +20 -0
- plastron_stomp-4.3.2/src/plastron/stomp/commands/update.py +45 -0
- plastron_stomp-4.3.2/src/plastron/stomp/daemon.py +116 -0
- plastron_stomp-4.3.2/src/plastron/stomp/handlers.py +55 -0
- plastron_stomp-4.3.2/src/plastron/stomp/inbox_watcher.py +49 -0
- plastron_stomp-4.3.2/src/plastron/stomp/listeners.py +143 -0
- plastron_stomp-4.3.2/src/plastron_stomp.egg-info/PKG-INFO +125 -0
- plastron_stomp-4.3.2/src/plastron_stomp.egg-info/SOURCES.txt +24 -0
- plastron_stomp-4.3.2/src/plastron_stomp.egg-info/dependency_links.txt +1 -0
- plastron_stomp-4.3.2/src/plastron_stomp.egg-info/entry_points.txt +2 -0
- plastron_stomp-4.3.2/src/plastron_stomp.egg-info/requires.txt +14 -0
- plastron_stomp-4.3.2/src/plastron_stomp.egg-info/top_level.txt +1 -0
- plastron_stomp-4.3.2/tests/test_connection.py +76 -0
- plastron_stomp-4.3.2/tests/test_handlers.py +80 -0
- plastron_stomp-4.3.2/tests/test_inbox_watcher.py +52 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: plastron-stomp
|
|
3
|
+
Version: 4.3.2
|
|
4
|
+
Summary: Plastron STOMP daemon
|
|
5
|
+
Author-email: University of Maryland Libraries <lib-ssdr@umd.edu>, Josh Westgard <westgard@umd.edu>, Peter Eichman <peichman@umd.edu>, Mohamed Abdul Rasheed <mohideen@umd.edu>, Ben Wallberg <wallberg@umd.edu>, David Steelman <dsteelma@umd.edu>, Marc Andreu Grillo Aguilar <aguilarm@umd.edu>
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Requires-Dist: BeautifulSoup4
|
|
10
|
+
Requires-Dist: plastron-models
|
|
11
|
+
Requires-Dist: plastron-repo
|
|
12
|
+
Requires-Dist: plastron-utils
|
|
13
|
+
Requires-Dist: pyparsing
|
|
14
|
+
Requires-Dist: PyYAML
|
|
15
|
+
Requires-Dist: stomp.py
|
|
16
|
+
Requires-Dist: watchdog==0.10.3
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: CoilMQ; extra == "test"
|
|
19
|
+
Requires-Dist: pytest; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
21
|
+
|
|
22
|
+
# plastron-stomp
|
|
23
|
+
|
|
24
|
+
STOMP listener client for asynchronous and synchronous operations
|
|
25
|
+
|
|
26
|
+
## Running with Python
|
|
27
|
+
|
|
28
|
+
As a Python module:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python -m plastron.stomp.daemon -c <config file>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Using the console script entrypoint:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
plastrond-stomp -c <config file>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Integration Tests
|
|
41
|
+
|
|
42
|
+
See the [integration test README](integration-tests/README.md) for
|
|
43
|
+
instructions on running the manual integration tests.
|
|
44
|
+
|
|
45
|
+
## Docker Image
|
|
46
|
+
|
|
47
|
+
The plastron-stomp package contains a [Dockerfile](Dockerfile) for
|
|
48
|
+
building the `plastrond-stomp` Docker image.
|
|
49
|
+
|
|
50
|
+
### Building
|
|
51
|
+
|
|
52
|
+
**Important:** This image **MUST** be built from the main _plastron_
|
|
53
|
+
project directory, in order to include the other plastron packages in the
|
|
54
|
+
build context.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
|
|
58
|
+
-f plastron-stomp/Dockerfile .
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Running with Docker Swarm
|
|
62
|
+
|
|
63
|
+
This repository contains a [compose.yml](compose.yml) file that defines
|
|
64
|
+
part of a `plastrond` Docker stack intended to be run alongside the
|
|
65
|
+
[umd-fcrepo-docker] stack. This repository's configuration adds a
|
|
66
|
+
`plastrond-stomp` container.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# if you are not already running in swarm mode
|
|
70
|
+
docker swarm init
|
|
71
|
+
|
|
72
|
+
# build the image
|
|
73
|
+
docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
|
|
74
|
+
-f plastron-stomp/Dockerfile .
|
|
75
|
+
|
|
76
|
+
# Create an "archelon_id" private/public key pair
|
|
77
|
+
# The Archelon instance should be configured with "archelon_id.pub" as the
|
|
78
|
+
# PLASTRON_PUBLIC_KEY
|
|
79
|
+
ssh-keygen -q -t rsa -N '' -f archelon_id
|
|
80
|
+
|
|
81
|
+
# Copy the docker-plastron-template.yml and edit the configuration
|
|
82
|
+
cp docker-plastron.template.yml docker-plastron.yml
|
|
83
|
+
vim docker-plastron.yml
|
|
84
|
+
|
|
85
|
+
# deploy the stack to run the STOMP application
|
|
86
|
+
docker stack deploy -c plastron-stomp/compose.yml plastrond
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
To watch the logs:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
docker service logs -f plastrond_stomp
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
To stop the STOMP service:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
docker service rm plastrond_stomp
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Configuration
|
|
102
|
+
|
|
103
|
+
See [docker-plastron.template.yml](../docker-plastron.template.yml) for an
|
|
104
|
+
example of the config file.
|
|
105
|
+
|
|
106
|
+
## STOMP Message Headers
|
|
107
|
+
|
|
108
|
+
The Plastron Daemon expects the following headers to be present in messages
|
|
109
|
+
received on the `JOBS` destination:
|
|
110
|
+
|
|
111
|
+
* `PlastronCommand`
|
|
112
|
+
* `PlastronJobId`
|
|
113
|
+
|
|
114
|
+
Additional arguments for a command are sent in headers with the form `PlastronArg-{name}`.
|
|
115
|
+
Many of these are specific to the command, but there is one with standard behavior across
|
|
116
|
+
all commands:
|
|
117
|
+
|
|
118
|
+
| Header | Description |
|
|
119
|
+
|----------------------------|-----------------------------------------------|
|
|
120
|
+
| `PlastronArg-on-behalf-of` | Username to delegate repository operations to |
|
|
121
|
+
|
|
122
|
+
See the [messages documentation](docs/messages.md) for details on the headers
|
|
123
|
+
and bodies of the messages the Plastron STOMP Daemon emits.
|
|
124
|
+
|
|
125
|
+
[umd-fcrepo-docker]: https://github.com/umd-lib/umd-fcrepo-docker
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# plastron-stomp
|
|
2
|
+
|
|
3
|
+
STOMP listener client for asynchronous and synchronous operations
|
|
4
|
+
|
|
5
|
+
## Running with Python
|
|
6
|
+
|
|
7
|
+
As a Python module:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m plastron.stomp.daemon -c <config file>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Using the console script entrypoint:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
plastrond-stomp -c <config file>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Integration Tests
|
|
20
|
+
|
|
21
|
+
See the [integration test README](integration-tests/README.md) for
|
|
22
|
+
instructions on running the manual integration tests.
|
|
23
|
+
|
|
24
|
+
## Docker Image
|
|
25
|
+
|
|
26
|
+
The plastron-stomp package contains a [Dockerfile](Dockerfile) for
|
|
27
|
+
building the `plastrond-stomp` Docker image.
|
|
28
|
+
|
|
29
|
+
### Building
|
|
30
|
+
|
|
31
|
+
**Important:** This image **MUST** be built from the main _plastron_
|
|
32
|
+
project directory, in order to include the other plastron packages in the
|
|
33
|
+
build context.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
|
|
37
|
+
-f plastron-stomp/Dockerfile .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Running with Docker Swarm
|
|
41
|
+
|
|
42
|
+
This repository contains a [compose.yml](compose.yml) file that defines
|
|
43
|
+
part of a `plastrond` Docker stack intended to be run alongside the
|
|
44
|
+
[umd-fcrepo-docker] stack. This repository's configuration adds a
|
|
45
|
+
`plastrond-stomp` container.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# if you are not already running in swarm mode
|
|
49
|
+
docker swarm init
|
|
50
|
+
|
|
51
|
+
# build the image
|
|
52
|
+
docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
|
|
53
|
+
-f plastron-stomp/Dockerfile .
|
|
54
|
+
|
|
55
|
+
# Create an "archelon_id" private/public key pair
|
|
56
|
+
# The Archelon instance should be configured with "archelon_id.pub" as the
|
|
57
|
+
# PLASTRON_PUBLIC_KEY
|
|
58
|
+
ssh-keygen -q -t rsa -N '' -f archelon_id
|
|
59
|
+
|
|
60
|
+
# Copy the docker-plastron-template.yml and edit the configuration
|
|
61
|
+
cp docker-plastron.template.yml docker-plastron.yml
|
|
62
|
+
vim docker-plastron.yml
|
|
63
|
+
|
|
64
|
+
# deploy the stack to run the STOMP application
|
|
65
|
+
docker stack deploy -c plastron-stomp/compose.yml plastrond
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
To watch the logs:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
docker service logs -f plastrond_stomp
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To stop the STOMP service:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
docker service rm plastrond_stomp
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
See [docker-plastron.template.yml](../docker-plastron.template.yml) for an
|
|
83
|
+
example of the config file.
|
|
84
|
+
|
|
85
|
+
## STOMP Message Headers
|
|
86
|
+
|
|
87
|
+
The Plastron Daemon expects the following headers to be present in messages
|
|
88
|
+
received on the `JOBS` destination:
|
|
89
|
+
|
|
90
|
+
* `PlastronCommand`
|
|
91
|
+
* `PlastronJobId`
|
|
92
|
+
|
|
93
|
+
Additional arguments for a command are sent in headers with the form `PlastronArg-{name}`.
|
|
94
|
+
Many of these are specific to the command, but there is one with standard behavior across
|
|
95
|
+
all commands:
|
|
96
|
+
|
|
97
|
+
| Header | Description |
|
|
98
|
+
|----------------------------|-----------------------------------------------|
|
|
99
|
+
| `PlastronArg-on-behalf-of` | Username to delegate repository operations to |
|
|
100
|
+
|
|
101
|
+
See the [messages documentation](docs/messages.md) for details on the headers
|
|
102
|
+
and bodies of the messages the Plastron STOMP Daemon emits.
|
|
103
|
+
|
|
104
|
+
[umd-fcrepo-docker]: https://github.com/umd-lib/umd-fcrepo-docker
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.3.2
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "plastron-stomp"
|
|
3
|
+
description = "Plastron STOMP daemon"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name='University of Maryland Libraries', email='lib-ssdr@umd.edu' },
|
|
6
|
+
{ name='Josh Westgard', email='westgard@umd.edu' },
|
|
7
|
+
{ name='Peter Eichman', email='peichman@umd.edu' },
|
|
8
|
+
{ name='Mohamed Abdul Rasheed', email='mohideen@umd.edu' },
|
|
9
|
+
{ name='Ben Wallberg', email='wallberg@umd.edu' },
|
|
10
|
+
{ name='David Steelman', email='dsteelma@umd.edu' },
|
|
11
|
+
{ name='Marc Andreu Grillo Aguilar', email='aguilarm@umd.edu' },
|
|
12
|
+
]
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">= 3.8"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"click",
|
|
17
|
+
"BeautifulSoup4",
|
|
18
|
+
"plastron-models",
|
|
19
|
+
"plastron-repo",
|
|
20
|
+
"plastron-utils",
|
|
21
|
+
"pyparsing",
|
|
22
|
+
"PyYAML",
|
|
23
|
+
"stomp.py",
|
|
24
|
+
'watchdog==0.10.3',
|
|
25
|
+
]
|
|
26
|
+
dynamic = ["version"]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = [
|
|
30
|
+
'CoilMQ',
|
|
31
|
+
"pytest",
|
|
32
|
+
"pytest-cov",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
plastrond-stomp = 'plastron.stomp.daemon:main'
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["setuptools>=66.1.0"]
|
|
40
|
+
build-backend = "setuptools.build_meta"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.dynamic]
|
|
43
|
+
version = { "file" = "VERSION" }
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
markers = ['jobs_dir']
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
from types import ModuleType
|
|
3
|
+
from typing import Optional, Dict, TypeVar, Type
|
|
4
|
+
|
|
5
|
+
from plastron.repo import Repository
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseCommand:
|
|
9
|
+
def __init__(self, config=None):
|
|
10
|
+
if config is None:
|
|
11
|
+
config = {}
|
|
12
|
+
self.config = config
|
|
13
|
+
self.repo: Optional[Repository] = None
|
|
14
|
+
self.result: Optional[Dict] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
T = TypeVar('T', bound=BaseCommand)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_module_name(command_name: str) -> str:
|
|
21
|
+
if command_name == 'import':
|
|
22
|
+
# special case for the import command, to avoid conflict
|
|
23
|
+
# with the "import" keyword
|
|
24
|
+
return command_name + 'command'
|
|
25
|
+
else:
|
|
26
|
+
return command_name
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_command_module(command_name: str) -> ModuleType:
|
|
30
|
+
try:
|
|
31
|
+
return import_module(".".join((__package__, get_module_name(command_name))))
|
|
32
|
+
except ModuleNotFoundError as e:
|
|
33
|
+
raise RuntimeError(f'Unable to load a command with the name "{command_name}"') from e
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_command_class(command_name: str) -> Type[BaseCommand]:
|
|
37
|
+
command_module = get_command_module(command_name)
|
|
38
|
+
command_class = getattr(command_module, 'Command')
|
|
39
|
+
if command_class is None:
|
|
40
|
+
raise RuntimeError(f'Command class not found in module "{command_module}"')
|
|
41
|
+
|
|
42
|
+
return command_class
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import Generator, Any, Dict
|
|
4
|
+
|
|
5
|
+
from plastron.messaging.messages import PlastronCommandMessage
|
|
6
|
+
from plastron.repo import Repository
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def echo(
|
|
12
|
+
_repo: Repository,
|
|
13
|
+
_config: Dict[str, Any],
|
|
14
|
+
message: PlastronCommandMessage,
|
|
15
|
+
) -> Generator[Any, None, Dict[str, Any]]:
|
|
16
|
+
message_body = message.body.encode('utf-8').decode('utf-8-sig')
|
|
17
|
+
echo_delay = int(message.args.get('echo-delay', "0"))
|
|
18
|
+
if echo_delay:
|
|
19
|
+
time.sleep(echo_delay)
|
|
20
|
+
|
|
21
|
+
yield {'echo': message_body}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
'type': 'Done',
|
|
25
|
+
'echo': message_body,
|
|
26
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Generator, Any, Dict
|
|
3
|
+
|
|
4
|
+
from plastron.context import PlastronContext
|
|
5
|
+
from plastron.jobs.exportjob import ExportJob
|
|
6
|
+
from plastron.messaging.messages import PlastronCommandMessage
|
|
7
|
+
from plastron.utils import strtobool
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def export(
|
|
13
|
+
context: PlastronContext,
|
|
14
|
+
message: PlastronCommandMessage,
|
|
15
|
+
) -> Generator[Dict[str, Any], None, Dict[str, Any]]:
|
|
16
|
+
ssh_key = context.config.get('COMMANDS', {}).get('EXPORT', {}).get('SSH_PRIVATE_KEY', None)
|
|
17
|
+
export_job = ExportJob(
|
|
18
|
+
context=context,
|
|
19
|
+
export_binaries=bool(strtobool(message.args.get('export-binaries', 'false'))),
|
|
20
|
+
binary_types=message.args.get('binary-types'),
|
|
21
|
+
uris=message.body.strip().split('\n'),
|
|
22
|
+
export_format=message.args.get('format', 'text/turtle'),
|
|
23
|
+
output_dest=message.args.get('output-dest'),
|
|
24
|
+
uri_template=message.args.get('uri-template'),
|
|
25
|
+
key=ssh_key,
|
|
26
|
+
)
|
|
27
|
+
logger.info(f'Received message to initiate export job {message.job_id} containing {len(export_job.uris)} items')
|
|
28
|
+
return export_job.run()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import logging
|
|
3
|
+
from argparse import ArgumentTypeError
|
|
4
|
+
from typing import Any, Dict, Generator, Optional
|
|
5
|
+
|
|
6
|
+
from rdflib import URIRef
|
|
7
|
+
|
|
8
|
+
from plastron.context import PlastronContext
|
|
9
|
+
from plastron.jobs import Jobs
|
|
10
|
+
from plastron.jobs.importjob import ImportConfig, ImportJob
|
|
11
|
+
from plastron.messaging.messages import PlastronCommandMessage
|
|
12
|
+
from plastron.utils import datetimestamp, strtobool, uri_or_curie
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_access_uri(access) -> Optional[URIRef]:
|
|
18
|
+
if access is None:
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
return uri_or_curie(access)
|
|
22
|
+
except ArgumentTypeError as e:
|
|
23
|
+
raise RuntimeError(f'PlastronArg-access {e}')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def importcommand(
|
|
27
|
+
context: PlastronContext,
|
|
28
|
+
message: PlastronCommandMessage,
|
|
29
|
+
) -> Generator[Dict[str, Any], None, Dict[str, Any]]:
|
|
30
|
+
"""
|
|
31
|
+
Performs the import
|
|
32
|
+
|
|
33
|
+
:param context
|
|
34
|
+
:param message:
|
|
35
|
+
"""
|
|
36
|
+
job_id = message.job_id
|
|
37
|
+
|
|
38
|
+
# per-request options that are NOT saved to the config
|
|
39
|
+
limit = message.args.get('limit', None)
|
|
40
|
+
if limit is not None:
|
|
41
|
+
limit = int(limit)
|
|
42
|
+
message.body = message.body.encode('utf-8').decode('utf-8-sig')
|
|
43
|
+
percentage = message.args.get('percent', None)
|
|
44
|
+
validate_only = bool(strtobool(message.args.get('validate-only', 'false')))
|
|
45
|
+
publish = bool(strtobool(message.args.get('publish', 'false')))
|
|
46
|
+
resume = bool(strtobool(message.args.get('resume', 'false')))
|
|
47
|
+
import_file = io.StringIO(message.body)
|
|
48
|
+
|
|
49
|
+
# options that are saved to the config
|
|
50
|
+
job_config_args = {
|
|
51
|
+
'job_id': job_id,
|
|
52
|
+
'model': message.args.get('model'),
|
|
53
|
+
'access': get_access_uri(message.args.get('access')),
|
|
54
|
+
'member_of': message.args.get('member-of'),
|
|
55
|
+
'container': message.args.get('relpath'),
|
|
56
|
+
'binaries_location': message.args.get('binaries-location'),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if resume and job_id is None:
|
|
60
|
+
raise RuntimeError('Resuming a job requires a job id')
|
|
61
|
+
|
|
62
|
+
if job_id is None:
|
|
63
|
+
# TODO: generate a more unique id? add in user and hostname?
|
|
64
|
+
job_id = f"import-{datetimestamp()}"
|
|
65
|
+
|
|
66
|
+
config = context.config.get('COMMANDS', {}).get('IMPORT', {})
|
|
67
|
+
jobs = Jobs(directory=config.get('JOBS_DIR', 'jobs'))
|
|
68
|
+
if resume:
|
|
69
|
+
job = jobs.get_job(ImportJob, job_id=job_id)
|
|
70
|
+
# update the config with any changes in this request
|
|
71
|
+
job.update_config(job_config_args)
|
|
72
|
+
else:
|
|
73
|
+
job = jobs.create_job(ImportJob, config=ImportConfig(**job_config_args))
|
|
74
|
+
|
|
75
|
+
job.ssh_private_key = config.get('SSH_PRIVATE_KEY', None)
|
|
76
|
+
|
|
77
|
+
return job.run(
|
|
78
|
+
context=context,
|
|
79
|
+
import_file=import_file,
|
|
80
|
+
limit=limit,
|
|
81
|
+
percentage=percentage,
|
|
82
|
+
validate_only=validate_only,
|
|
83
|
+
publish=publish,
|
|
84
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Dict, Any, Generator
|
|
2
|
+
|
|
3
|
+
from plastron.context import PlastronContext
|
|
4
|
+
from plastron.jobs.publicationjob import PublicationJob, PublicationAction
|
|
5
|
+
from plastron.messaging.messages import PlastronCommandMessage
|
|
6
|
+
from plastron.utils import strtobool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def publish(
|
|
10
|
+
context: PlastronContext,
|
|
11
|
+
message: PlastronCommandMessage,
|
|
12
|
+
) -> Generator[Dict[str, Any], None, Dict[str, Any]]:
|
|
13
|
+
job = PublicationJob(
|
|
14
|
+
context=context,
|
|
15
|
+
uris=message.body.strip().split('\n'),
|
|
16
|
+
action=PublicationAction.PUBLISH,
|
|
17
|
+
force_hidden=bool(strtobool(message.args.get('hidden', 'false'))),
|
|
18
|
+
force_visible=bool(strtobool(message.args.get('visible', 'false'))),
|
|
19
|
+
)
|
|
20
|
+
return job.run()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Dict, Any, Generator
|
|
2
|
+
|
|
3
|
+
from plastron.context import PlastronContext
|
|
4
|
+
from plastron.jobs.publicationjob import PublicationJob, PublicationAction
|
|
5
|
+
from plastron.messaging.messages import PlastronCommandMessage
|
|
6
|
+
from plastron.utils import strtobool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def unpublish(
|
|
10
|
+
context: PlastronContext,
|
|
11
|
+
message: PlastronCommandMessage,
|
|
12
|
+
) -> Generator[Dict[str, Any], None, Dict[str, Any]]:
|
|
13
|
+
job = PublicationJob(
|
|
14
|
+
context=context,
|
|
15
|
+
uris=message.body.strip().split('\n'),
|
|
16
|
+
action=PublicationAction.UNPUBLISH,
|
|
17
|
+
force_hidden=bool(strtobool(message.args.get('hidden', 'false'))),
|
|
18
|
+
force_visible=bool(strtobool(message.args.get('visible', 'false'))),
|
|
19
|
+
)
|
|
20
|
+
return job.run()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Generator, Any, Dict
|
|
4
|
+
|
|
5
|
+
from plastron.context import PlastronContext
|
|
6
|
+
from plastron.jobs.updatejob import UpdateJob
|
|
7
|
+
from plastron.messaging.messages import PlastronCommandMessage
|
|
8
|
+
from plastron.models import get_model_class
|
|
9
|
+
from plastron.utils import strtobool, parse_predicate_list
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_message(message: PlastronCommandMessage) -> Dict[str, Any]:
|
|
15
|
+
message.body = message.body.encode('utf-8').decode('utf-8-sig')
|
|
16
|
+
body = json.loads(message.body)
|
|
17
|
+
uris = body['uris']
|
|
18
|
+
sparql_update = body['sparql_update']
|
|
19
|
+
validate = bool(strtobool(message.args.get('validate', 'false')))
|
|
20
|
+
model = message.args.get('model', None)
|
|
21
|
+
recursive = message.args.get('recursive', None)
|
|
22
|
+
|
|
23
|
+
if validate and not model:
|
|
24
|
+
raise RuntimeError("Model must be provided when performing validation")
|
|
25
|
+
|
|
26
|
+
# Retrieve the model to use for validation
|
|
27
|
+
model_class = get_model_class(model) if model else None
|
|
28
|
+
|
|
29
|
+
traverse = parse_predicate_list(recursive) if recursive is not None else []
|
|
30
|
+
return {
|
|
31
|
+
'uris': uris,
|
|
32
|
+
'sparql_update': sparql_update,
|
|
33
|
+
'model_class': model_class,
|
|
34
|
+
'traverse': traverse,
|
|
35
|
+
'dry_run': bool(strtobool(message.args.get('dry-run', 'false'))),
|
|
36
|
+
# Default to no transactions, due to LIBFCREPO-842
|
|
37
|
+
'use_transactions': not bool(strtobool(message.args.get('no-transactions', 'true'))),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def update(
|
|
42
|
+
context: PlastronContext,
|
|
43
|
+
message: PlastronCommandMessage,
|
|
44
|
+
) -> Generator[Dict[str, str], None, Dict[str, Any]]:
|
|
45
|
+
return UpdateJob(repo=context.repo, **parse_message(message)).run()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import logging.config
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Event, Thread
|
|
8
|
+
from typing import TextIO, Dict, Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from plastron.context import PlastronContext
|
|
14
|
+
from plastron.stomp import __version__
|
|
15
|
+
from plastron.stomp.listeners import CommandListener
|
|
16
|
+
from plastron.utils import DEFAULT_LOGGING_OPTIONS, envsubst
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class STOMPDaemon(Thread):
|
|
22
|
+
def __init__(self, config: Dict[str, Any], **kwargs):
|
|
23
|
+
super().__init__(**kwargs)
|
|
24
|
+
self.context = PlastronContext(config)
|
|
25
|
+
self.started = Event()
|
|
26
|
+
self.stopped = Event()
|
|
27
|
+
self.broker = self.context.broker
|
|
28
|
+
|
|
29
|
+
def started():
|
|
30
|
+
self.stopped.clear()
|
|
31
|
+
self.started.set()
|
|
32
|
+
|
|
33
|
+
def stopped():
|
|
34
|
+
self.started.clear()
|
|
35
|
+
self.stopped.set()
|
|
36
|
+
|
|
37
|
+
self.command_listener = CommandListener(
|
|
38
|
+
context=self.context,
|
|
39
|
+
after_connected=started,
|
|
40
|
+
after_disconnected=stopped,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def run(self):
|
|
44
|
+
# setup listeners
|
|
45
|
+
self.broker.set_listener('command', self.command_listener)
|
|
46
|
+
|
|
47
|
+
# connect and listen until the stopped Event is set
|
|
48
|
+
if self.broker.connect(client_id=f'plastrond/{__version__}-{os.uname().nodename}-{os.getpid()}'):
|
|
49
|
+
self.stopped.wait()
|
|
50
|
+
|
|
51
|
+
self.broker.disconnect()
|
|
52
|
+
if self.command_listener.inbox_watcher:
|
|
53
|
+
self.command_listener.inbox_watcher.stop()
|
|
54
|
+
else:
|
|
55
|
+
logger.error('Unable to connect to STOMP broker')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def configure_logging(log_filename_base: str, log_dir: str = 'logs', verbose: bool = False) -> None:
|
|
59
|
+
logging_options = DEFAULT_LOGGING_OPTIONS
|
|
60
|
+
|
|
61
|
+
# log file configuration
|
|
62
|
+
log_dirname = Path(log_dir)
|
|
63
|
+
log_dirname.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
now = datetime.utcnow().strftime('%Y%m%d%H%M%S')
|
|
65
|
+
log_filename = '.'.join((log_filename_base, now, 'log'))
|
|
66
|
+
logfile = log_dirname / log_filename
|
|
67
|
+
logging_options['handlers']['file']['filename'] = str(logfile)
|
|
68
|
+
|
|
69
|
+
# manipulate console verbosity
|
|
70
|
+
if verbose:
|
|
71
|
+
logging_options['handlers']['console']['level'] = 'DEBUG'
|
|
72
|
+
|
|
73
|
+
# configure logging
|
|
74
|
+
logging.config.dictConfig(logging_options)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@click.command
|
|
78
|
+
@click.option(
|
|
79
|
+
'-c', '--config-file',
|
|
80
|
+
type=click.File(),
|
|
81
|
+
help='Configuration file',
|
|
82
|
+
required=True,
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
'-v', '--verbose',
|
|
86
|
+
is_flag=True,
|
|
87
|
+
help='increase the verbosity of the status output',
|
|
88
|
+
)
|
|
89
|
+
def main(config_file: TextIO, verbose: bool):
|
|
90
|
+
config = envsubst(yaml.safe_load(config_file))
|
|
91
|
+
repo_config = config.get('REPOSITORY', {})
|
|
92
|
+
configure_logging(
|
|
93
|
+
log_filename_base='plastron.daemon',
|
|
94
|
+
log_dir=repo_config.get('LOG_DIR', 'logs'),
|
|
95
|
+
verbose=verbose,
|
|
96
|
+
)
|
|
97
|
+
daemon_description = f'plastrond-stomp/{__version__}'
|
|
98
|
+
logger.info(f'Starting {daemon_description}')
|
|
99
|
+
thread = STOMPDaemon(config=config)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
thread.start()
|
|
103
|
+
while thread.is_alive():
|
|
104
|
+
thread.join(1)
|
|
105
|
+
except KeyboardInterrupt:
|
|
106
|
+
logger.warning(f'Shutting down {daemon_description}')
|
|
107
|
+
if hasattr(thread, 'stopped'):
|
|
108
|
+
thread.stopped.set()
|
|
109
|
+
thread.stopped.wait()
|
|
110
|
+
|
|
111
|
+
logger.info(f'{daemon_description} shut down successfully')
|
|
112
|
+
sys.exit()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from plastron.messaging.broker import Destination
|
|
4
|
+
from plastron.messaging.messages import PlastronMessage, PlastronErrorMessage
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsynchronousResponseHandler:
|
|
10
|
+
def __init__(self, listener, message: PlastronMessage):
|
|
11
|
+
self.listener = listener
|
|
12
|
+
self.message = message
|
|
13
|
+
self.reply_queue: Destination = listener.broker['JOB_STATUS']
|
|
14
|
+
|
|
15
|
+
def __call__(self, future):
|
|
16
|
+
e = future.exception()
|
|
17
|
+
if e:
|
|
18
|
+
logger.error(f"Job {self.message.job_id} failed: {e}")
|
|
19
|
+
response = PlastronErrorMessage(job_id=self.message.job_id, error=str(e))
|
|
20
|
+
else:
|
|
21
|
+
# assume no errors, return the response
|
|
22
|
+
response = future.result()
|
|
23
|
+
|
|
24
|
+
# save a copy of the response message in the outbox
|
|
25
|
+
job_id = response.job_id
|
|
26
|
+
self.listener.outbox.add(job_id, response)
|
|
27
|
+
|
|
28
|
+
# remove the message from the inbox now that processing has completed
|
|
29
|
+
self.listener.inbox.remove(self.message.id)
|
|
30
|
+
|
|
31
|
+
# send the job completed message
|
|
32
|
+
self.reply_queue.send(response)
|
|
33
|
+
logger.debug(f'Response message sent to {self.reply_queue} with headers: {response.headers}')
|
|
34
|
+
|
|
35
|
+
# remove the message from the outbox now that sending has completed
|
|
36
|
+
self.listener.outbox.remove(job_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SynchronousResponseHandler:
|
|
40
|
+
def __init__(self, listener, message: PlastronMessage):
|
|
41
|
+
self.listener = listener
|
|
42
|
+
self.message = message
|
|
43
|
+
self.reply_queue = Destination(self.listener.broker, message.headers['reply-to'])
|
|
44
|
+
|
|
45
|
+
def __call__(self, future):
|
|
46
|
+
e = future.exception()
|
|
47
|
+
if e:
|
|
48
|
+
logger.error(f"Job {self.message.job_id} failed: {e}")
|
|
49
|
+
self.reply_queue.send(PlastronErrorMessage(job_id=self.message.job_id, error=str(e)))
|
|
50
|
+
else:
|
|
51
|
+
# assume no errors, return the response
|
|
52
|
+
response = future.result()
|
|
53
|
+
# send to the specified "reply to" queue
|
|
54
|
+
self.reply_queue.send(response)
|
|
55
|
+
logger.debug(f'Response message sent to {self.reply_queue} with headers: {response.headers}')
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from plastron.stomp.handlers import AsynchronousResponseHandler
|
|
4
|
+
from watchdog.observers import Observer
|
|
5
|
+
from watchdog.events import FileSystemEventHandler, FileCreatedEvent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InboxEventHandler(FileSystemEventHandler):
|
|
12
|
+
"""Triggers message processing when a file is added in the
|
|
13
|
+
inbox directory."""
|
|
14
|
+
# Note to maintainers: The original implementation of this class included
|
|
15
|
+
# both "on_created" and "on_modified" event handlers. On Mac OS X, new file
|
|
16
|
+
# creation only triggers the "on_created" event. On Linux, new file
|
|
17
|
+
# creation triggers both an "on_created" event and "on_modified" event,
|
|
18
|
+
# leading to duplicate processing (see LIBFCREPO-821).
|
|
19
|
+
def __init__(self, command_listener, message_box):
|
|
20
|
+
self.command_listener = command_listener
|
|
21
|
+
self.message_box = message_box
|
|
22
|
+
|
|
23
|
+
def on_created(self, event):
|
|
24
|
+
if isinstance(event, FileCreatedEvent):
|
|
25
|
+
logger.info(f"Triggering inbox processing due to {event}")
|
|
26
|
+
message = self.message_box.message_class.read(event.src_path)
|
|
27
|
+
self.command_listener.process_message(message, AsynchronousResponseHandler(self.command_listener, message))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InboxWatcher:
|
|
31
|
+
"""
|
|
32
|
+
Watches for changes to the inbox directory, in order to trigger message
|
|
33
|
+
processing via InboxEventHandler
|
|
34
|
+
"""
|
|
35
|
+
def __init__(self, command_listener, message_box):
|
|
36
|
+
"""Constructs the watchdog Observer"""
|
|
37
|
+
self.observer = Observer()
|
|
38
|
+
self.observer.schedule(InboxEventHandler(command_listener, message_box), message_box.dir, recursive=True)
|
|
39
|
+
|
|
40
|
+
def start(self):
|
|
41
|
+
"""Start the watcher"""
|
|
42
|
+
logger.debug("Starting InboxWatcher")
|
|
43
|
+
self.observer.start()
|
|
44
|
+
|
|
45
|
+
def stop(self):
|
|
46
|
+
"""Stop the watcher"""
|
|
47
|
+
logger.debug("Stopping InboxWatcher")
|
|
48
|
+
self.observer.unschedule_all()
|
|
49
|
+
self.observer.stop()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from typing import Dict, Any, Callable, Generator, Iterator
|
|
6
|
+
|
|
7
|
+
from stomp.listener import ConnectionListener
|
|
8
|
+
|
|
9
|
+
from plastron.context import PlastronContext
|
|
10
|
+
from plastron.messaging.broker import Destination
|
|
11
|
+
from plastron.messaging.messages import MessageBox, PlastronCommandMessage, PlastronMessage
|
|
12
|
+
from plastron.stomp.commands import get_command_module, get_module_name
|
|
13
|
+
from plastron.stomp.handlers import AsynchronousResponseHandler, SynchronousResponseHandler
|
|
14
|
+
from plastron.stomp.inbox_watcher import InboxWatcher
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
version = importlib.metadata.version('plastron-stomp')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CommandListener(ConnectionListener):
|
|
21
|
+
def __init__(self, context: PlastronContext, after_connected: Callable = None, after_disconnected: Callable = None):
|
|
22
|
+
self.context = context
|
|
23
|
+
self.broker = context.broker
|
|
24
|
+
self.inbox = MessageBox(os.path.join(self.broker.message_store_dir, 'inbox'), PlastronCommandMessage)
|
|
25
|
+
self.outbox = MessageBox(os.path.join(self.broker.message_store_dir, 'outbox'), PlastronMessage)
|
|
26
|
+
self.executor = ThreadPoolExecutor(thread_name_prefix=__name__)
|
|
27
|
+
self.inbox_watcher = None
|
|
28
|
+
self.processor = MessageProcessor(context)
|
|
29
|
+
self.after_connected = after_connected
|
|
30
|
+
self.after_disconnected = after_disconnected
|
|
31
|
+
|
|
32
|
+
def on_connecting(self, host_and_port):
|
|
33
|
+
logger.info(f'Connecting to STOMP message broker {self.broker}')
|
|
34
|
+
|
|
35
|
+
def on_connected(self, frame):
|
|
36
|
+
logger.info(f'Connected to STOMP message broker {self.broker}')
|
|
37
|
+
|
|
38
|
+
# first attempt to send anything in the outbox
|
|
39
|
+
for message in self.outbox:
|
|
40
|
+
logger.info(f"Found response message for job {message.job_id} in outbox")
|
|
41
|
+
# send the job completed message
|
|
42
|
+
self.broker['JOB_STATUS'].send(message)
|
|
43
|
+
logger.info(f'Sent response message for job {message.job_id}')
|
|
44
|
+
# remove the message from the outbox now that sending has completed
|
|
45
|
+
self.outbox.remove(message.job_id)
|
|
46
|
+
|
|
47
|
+
# then process anything in the inbox
|
|
48
|
+
for message in self.inbox:
|
|
49
|
+
self.process_message(message, AsynchronousResponseHandler(self, message))
|
|
50
|
+
|
|
51
|
+
# subscribe to receive asynchronous jobs
|
|
52
|
+
self.broker['JOBS'].subscribe(id='plastron', ack='client-individual')
|
|
53
|
+
# subscribe to receive synchronous jobs
|
|
54
|
+
self.broker['SYNCHRONOUS_JOBS'].subscribe(id='plastron-synchronous', ack='client-individual')
|
|
55
|
+
|
|
56
|
+
self.inbox_watcher = InboxWatcher(self, self.inbox)
|
|
57
|
+
self.inbox_watcher.start()
|
|
58
|
+
|
|
59
|
+
if self.after_connected:
|
|
60
|
+
self.after_connected()
|
|
61
|
+
|
|
62
|
+
def on_message(self, frame):
|
|
63
|
+
headers = frame.headers
|
|
64
|
+
body = frame.body
|
|
65
|
+
logger.debug(f'Received message on {headers["destination"]} with headers: {headers}')
|
|
66
|
+
if headers['destination'] == self.broker['SYNCHRONOUS_JOBS'].name:
|
|
67
|
+
message = PlastronCommandMessage(headers=headers, body=body)
|
|
68
|
+
self.process_message(message, SynchronousResponseHandler(self, message))
|
|
69
|
+
self.broker.ack(message.id, 'plastron-synchronous')
|
|
70
|
+
|
|
71
|
+
elif headers['destination'] == self.broker['JOBS'].name:
|
|
72
|
+
# save the message in the inbox until we can process it
|
|
73
|
+
# Note: Processing will occur via the InboxWatcher, which will
|
|
74
|
+
# respond to the inbox placing a file in the inbox message directory
|
|
75
|
+
# containing the message
|
|
76
|
+
message = PlastronCommandMessage(headers=headers, body=body)
|
|
77
|
+
self.inbox.add(message.id, message)
|
|
78
|
+
self.broker.ack(message.id, 'plastron')
|
|
79
|
+
|
|
80
|
+
def process_message(self, message, response_handler):
|
|
81
|
+
# send to a message processor thread
|
|
82
|
+
self.executor.submit(self.processor, message, self.broker['JOB_PROGRESS']).add_done_callback(response_handler)
|
|
83
|
+
|
|
84
|
+
def on_disconnected(self):
|
|
85
|
+
logger.warning('Disconnected from the STOMP message broker')
|
|
86
|
+
if self.inbox_watcher:
|
|
87
|
+
self.inbox_watcher.stop()
|
|
88
|
+
if self.after_disconnected:
|
|
89
|
+
self.after_disconnected()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# type alias for command functions that take a context and a message, and return a generator that yields
|
|
93
|
+
# status updates in the form of dictionaries, and returns a final state, also as a dictionary
|
|
94
|
+
STOMPCommandFunction = Callable[[PlastronContext, PlastronMessage], Generator[Dict[str, Any], None, Dict[str, Any]]]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_command(command_name: str) -> STOMPCommandFunction:
|
|
98
|
+
module_name = get_module_name(command_name)
|
|
99
|
+
module = get_command_module(command_name)
|
|
100
|
+
command = getattr(module, module_name, None)
|
|
101
|
+
if command is None:
|
|
102
|
+
raise RuntimeError(f'Command function "{module_name}" not found in {module.__name__}')
|
|
103
|
+
return command
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class MessageProcessor:
|
|
107
|
+
def __init__(self, context: PlastronContext):
|
|
108
|
+
self.context = context
|
|
109
|
+
# cache for command instances
|
|
110
|
+
self.commands = {}
|
|
111
|
+
self.result = None
|
|
112
|
+
|
|
113
|
+
def __call__(self, message: PlastronCommandMessage, progress_topic: Destination):
|
|
114
|
+
if message.job_id is None:
|
|
115
|
+
raise RuntimeError('Expecting a PlastronJobId header')
|
|
116
|
+
|
|
117
|
+
logger.info(f'Received message to initiate job {message.job_id}')
|
|
118
|
+
|
|
119
|
+
# determine which command to load to process the message
|
|
120
|
+
command = get_command(message.command)
|
|
121
|
+
|
|
122
|
+
delegated_user = message.args.get('on-behalf-of')
|
|
123
|
+
if delegated_user is not None:
|
|
124
|
+
logger.info(f'Running repository operations on behalf of {delegated_user}')
|
|
125
|
+
|
|
126
|
+
# run the command, and send a progress message over STOMP every time it yields
|
|
127
|
+
# the _run() delegating generator captures the final status in self.result
|
|
128
|
+
with self.context.repo_configuration(
|
|
129
|
+
delegated_user=delegated_user,
|
|
130
|
+
ua_string=f'plastron/{version}',
|
|
131
|
+
) as run_context:
|
|
132
|
+
for status in self._run(command(run_context, message)):
|
|
133
|
+
progress_topic.send(PlastronMessage(job_id=message.job_id, body=status))
|
|
134
|
+
|
|
135
|
+
logger.info(f'Job {message.job_id} complete')
|
|
136
|
+
|
|
137
|
+
# default message state is "Done"
|
|
138
|
+
return message.response(state=self.result.get('type', 'Done'), body=self.result)
|
|
139
|
+
|
|
140
|
+
def _run(self, command: Generator[Dict, None, Dict]) -> Iterator[Dict[str, Any]]:
|
|
141
|
+
# delegating generator; each progress step is passed to the calling
|
|
142
|
+
# method, and the return value from the command is stored as the result
|
|
143
|
+
self.result = yield from command
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: plastron-stomp
|
|
3
|
+
Version: 4.3.2
|
|
4
|
+
Summary: Plastron STOMP daemon
|
|
5
|
+
Author-email: University of Maryland Libraries <lib-ssdr@umd.edu>, Josh Westgard <westgard@umd.edu>, Peter Eichman <peichman@umd.edu>, Mohamed Abdul Rasheed <mohideen@umd.edu>, Ben Wallberg <wallberg@umd.edu>, David Steelman <dsteelma@umd.edu>, Marc Andreu Grillo Aguilar <aguilarm@umd.edu>
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Requires-Dist: BeautifulSoup4
|
|
10
|
+
Requires-Dist: plastron-models
|
|
11
|
+
Requires-Dist: plastron-repo
|
|
12
|
+
Requires-Dist: plastron-utils
|
|
13
|
+
Requires-Dist: pyparsing
|
|
14
|
+
Requires-Dist: PyYAML
|
|
15
|
+
Requires-Dist: stomp.py
|
|
16
|
+
Requires-Dist: watchdog==0.10.3
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: CoilMQ; extra == "test"
|
|
19
|
+
Requires-Dist: pytest; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
21
|
+
|
|
22
|
+
# plastron-stomp
|
|
23
|
+
|
|
24
|
+
STOMP listener client for asynchronous and synchronous operations
|
|
25
|
+
|
|
26
|
+
## Running with Python
|
|
27
|
+
|
|
28
|
+
As a Python module:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python -m plastron.stomp.daemon -c <config file>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Using the console script entrypoint:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
plastrond-stomp -c <config file>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Integration Tests
|
|
41
|
+
|
|
42
|
+
See the [integration test README](integration-tests/README.md) for
|
|
43
|
+
instructions on running the manual integration tests.
|
|
44
|
+
|
|
45
|
+
## Docker Image
|
|
46
|
+
|
|
47
|
+
The plastron-stomp package contains a [Dockerfile](Dockerfile) for
|
|
48
|
+
building the `plastrond-stomp` Docker image.
|
|
49
|
+
|
|
50
|
+
### Building
|
|
51
|
+
|
|
52
|
+
**Important:** This image **MUST** be built from the main _plastron_
|
|
53
|
+
project directory, in order to include the other plastron packages in the
|
|
54
|
+
build context.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
|
|
58
|
+
-f plastron-stomp/Dockerfile .
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Running with Docker Swarm
|
|
62
|
+
|
|
63
|
+
This repository contains a [compose.yml](compose.yml) file that defines
|
|
64
|
+
part of a `plastrond` Docker stack intended to be run alongside the
|
|
65
|
+
[umd-fcrepo-docker] stack. This repository's configuration adds a
|
|
66
|
+
`plastrond-stomp` container.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# if you are not already running in swarm mode
|
|
70
|
+
docker swarm init
|
|
71
|
+
|
|
72
|
+
# build the image
|
|
73
|
+
docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
|
|
74
|
+
-f plastron-stomp/Dockerfile .
|
|
75
|
+
|
|
76
|
+
# Create an "archelon_id" private/public key pair
|
|
77
|
+
# The Archelon instance should be configured with "archelon_id.pub" as the
|
|
78
|
+
# PLASTRON_PUBLIC_KEY
|
|
79
|
+
ssh-keygen -q -t rsa -N '' -f archelon_id
|
|
80
|
+
|
|
81
|
+
# Copy the docker-plastron-template.yml and edit the configuration
|
|
82
|
+
cp docker-plastron.template.yml docker-plastron.yml
|
|
83
|
+
vim docker-plastron.yml
|
|
84
|
+
|
|
85
|
+
# deploy the stack to run the STOMP application
|
|
86
|
+
docker stack deploy -c plastron-stomp/compose.yml plastrond
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
To watch the logs:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
docker service logs -f plastrond_stomp
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
To stop the STOMP service:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
docker service rm plastrond_stomp
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Configuration
|
|
102
|
+
|
|
103
|
+
See [docker-plastron.template.yml](../docker-plastron.template.yml) for an
|
|
104
|
+
example of the config file.
|
|
105
|
+
|
|
106
|
+
## STOMP Message Headers
|
|
107
|
+
|
|
108
|
+
The Plastron Daemon expects the following headers to be present in messages
|
|
109
|
+
received on the `JOBS` destination:
|
|
110
|
+
|
|
111
|
+
* `PlastronCommand`
|
|
112
|
+
* `PlastronJobId`
|
|
113
|
+
|
|
114
|
+
Additional arguments for a command are sent in headers with the form `PlastronArg-{name}`.
|
|
115
|
+
Many of these are specific to the command, but there is one with standard behavior across
|
|
116
|
+
all commands:
|
|
117
|
+
|
|
118
|
+
| Header | Description |
|
|
119
|
+
|----------------------------|-----------------------------------------------|
|
|
120
|
+
| `PlastronArg-on-behalf-of` | Username to delegate repository operations to |
|
|
121
|
+
|
|
122
|
+
See the [messages documentation](docs/messages.md) for details on the headers
|
|
123
|
+
and bodies of the messages the Plastron STOMP Daemon emits.
|
|
124
|
+
|
|
125
|
+
[umd-fcrepo-docker]: https://github.com/umd-lib/umd-fcrepo-docker
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
VERSION
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/plastron/stomp/__init__.py
|
|
5
|
+
src/plastron/stomp/daemon.py
|
|
6
|
+
src/plastron/stomp/handlers.py
|
|
7
|
+
src/plastron/stomp/inbox_watcher.py
|
|
8
|
+
src/plastron/stomp/listeners.py
|
|
9
|
+
src/plastron/stomp/commands/__init__.py
|
|
10
|
+
src/plastron/stomp/commands/echo.py
|
|
11
|
+
src/plastron/stomp/commands/export.py
|
|
12
|
+
src/plastron/stomp/commands/importcommand.py
|
|
13
|
+
src/plastron/stomp/commands/publish.py
|
|
14
|
+
src/plastron/stomp/commands/unpublish.py
|
|
15
|
+
src/plastron/stomp/commands/update.py
|
|
16
|
+
src/plastron_stomp.egg-info/PKG-INFO
|
|
17
|
+
src/plastron_stomp.egg-info/SOURCES.txt
|
|
18
|
+
src/plastron_stomp.egg-info/dependency_links.txt
|
|
19
|
+
src/plastron_stomp.egg-info/entry_points.txt
|
|
20
|
+
src/plastron_stomp.egg-info/requires.txt
|
|
21
|
+
src/plastron_stomp.egg-info/top_level.txt
|
|
22
|
+
tests/test_connection.py
|
|
23
|
+
tests/test_handlers.py
|
|
24
|
+
tests/test_inbox_watcher.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
plastron
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from threading import Thread
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from coilmq.protocol import STOMP11
|
|
5
|
+
from coilmq.queue import QueueManager
|
|
6
|
+
from coilmq.server.socket_server import StompServer
|
|
7
|
+
from coilmq.store.memory import MemoryQueue
|
|
8
|
+
from coilmq.topic import TopicManager
|
|
9
|
+
|
|
10
|
+
from plastron.messaging.broker import ServerTuple, Broker
|
|
11
|
+
from plastron.stomp.daemon import STOMPDaemon
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture()
|
|
15
|
+
def server_address() -> ServerTuple:
|
|
16
|
+
return ServerTuple('localhost', 61613)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture()
|
|
20
|
+
def broker(server_address, shared_datadir) -> Broker:
|
|
21
|
+
return Broker(
|
|
22
|
+
server=server_address,
|
|
23
|
+
message_store_dir=(shared_datadir / 'msg'),
|
|
24
|
+
destinations={
|
|
25
|
+
'JOBS': '/queue/plastron.jobs',
|
|
26
|
+
'JOB_PROGRESS': '/topic/plastron.jobs.progress',
|
|
27
|
+
'JOB_STATUS': '/queue/plastron.jobs.status',
|
|
28
|
+
'SYNCHRONOUS_JOBS': '/queue/plastron.jobs.synchronous',
|
|
29
|
+
'REINDEXING': '/queue/reindex',
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture()
|
|
35
|
+
def stomp_server(server_address):
|
|
36
|
+
server = StompServer(
|
|
37
|
+
server_address=server_address,
|
|
38
|
+
queue_manager=QueueManager(store=MemoryQueue()),
|
|
39
|
+
topic_manager=TopicManager(),
|
|
40
|
+
protocol=STOMP11,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
yield server
|
|
44
|
+
|
|
45
|
+
server.server_close()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture()
|
|
49
|
+
def plastrond_stomp(broker):
|
|
50
|
+
plastrond = STOMPDaemon(broker=broker, repo_config={})
|
|
51
|
+
|
|
52
|
+
yield plastrond
|
|
53
|
+
|
|
54
|
+
if plastrond.is_alive():
|
|
55
|
+
plastrond.stopped.set()
|
|
56
|
+
plastrond.stopped.wait()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.skip('intermittent failures, test is unreliable')
|
|
60
|
+
def test_stomp_server_closed(stomp_server, plastrond_stomp):
|
|
61
|
+
server_thread = Thread(target=stomp_server.serve_forever)
|
|
62
|
+
server_thread.start()
|
|
63
|
+
assert server_thread.is_alive()
|
|
64
|
+
|
|
65
|
+
plastrond_stomp.start()
|
|
66
|
+
assert plastrond_stomp.is_alive()
|
|
67
|
+
# wait for startup and subscriptions to happen
|
|
68
|
+
plastrond_stomp.started.wait()
|
|
69
|
+
|
|
70
|
+
# stop the server
|
|
71
|
+
stomp_server.shutdown()
|
|
72
|
+
|
|
73
|
+
# wait for the daemon to shut down
|
|
74
|
+
plastrond_stomp.stopped.wait(10)
|
|
75
|
+
|
|
76
|
+
assert not plastrond_stomp.is_alive()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from concurrent.futures import Future
|
|
2
|
+
from typing import cast, Dict, Type
|
|
3
|
+
from unittest import TestCase
|
|
4
|
+
from unittest.mock import Mock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from plastron.messaging.broker import Destination
|
|
9
|
+
from plastron.messaging.messages import MessageBox, PlastronCommandMessage, PlastronErrorMessage, PlastronMessage, \
|
|
10
|
+
Message
|
|
11
|
+
from plastron.stomp.handlers import AsynchronousResponseHandler
|
|
12
|
+
from plastron.stomp.listeners import CommandListener
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def mock_listener():
|
|
17
|
+
return Mock(
|
|
18
|
+
spec=CommandListener,
|
|
19
|
+
inbox=Mock(MessageBox),
|
|
20
|
+
outbox=Mock(MessageBox),
|
|
21
|
+
broker={'JOB_STATUS': Mock(Destination)},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_asynchronous_response_handler_successful_call_removes_inbox_and_outbox_entries(mock_listener):
|
|
26
|
+
# Set up mocks
|
|
27
|
+
job_id = 'test_asynchronous_response_handler_successful_call-123'
|
|
28
|
+
|
|
29
|
+
# This future represents a successful call
|
|
30
|
+
future_response = PlastronMessage(headers={'PlastronJobId': job_id}, body="Success!")
|
|
31
|
+
future_mock_attrs = {'exception.return_value': None, 'result.return_value': future_response}
|
|
32
|
+
future = Mock(Future, **future_mock_attrs)
|
|
33
|
+
|
|
34
|
+
incoming_message = Mock(PlastronCommandMessage, id='incoming_test_message', job_id=job_id)
|
|
35
|
+
|
|
36
|
+
# Handle the incoming message
|
|
37
|
+
handler = AsynchronousResponseHandler(mock_listener, incoming_message)
|
|
38
|
+
handler(future)
|
|
39
|
+
|
|
40
|
+
# Verify outbox/inbox handling and message sending
|
|
41
|
+
mock_listener.outbox.add.assert_called_once_with(job_id, future_response)
|
|
42
|
+
mock_listener.inbox.remove.assert_called_once_with(incoming_message.id)
|
|
43
|
+
mock_listener.broker['JOB_STATUS'].send.assert_called_once_with(future_response)
|
|
44
|
+
mock_listener.outbox.remove.assert_called_once_with(job_id)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_asynchronous_response_handler_call_with_exception_removes_inbox_and_outbox_entries(mock_listener):
|
|
48
|
+
# Set up mocks
|
|
49
|
+
job_id = 'test_asynchronous_response_handler_call_with_exception-123'
|
|
50
|
+
|
|
51
|
+
# This future throw an exception, representing a failed call
|
|
52
|
+
future_response = PlastronErrorMessage(headers={'PlastronJobId': job_id})
|
|
53
|
+
exception_message = 'An error occurred'
|
|
54
|
+
future_mock_attrs = {'exception.return_value': Exception(exception_message), 'result.return_value': future_response}
|
|
55
|
+
future = Mock(Future, **future_mock_attrs)
|
|
56
|
+
|
|
57
|
+
incoming_message = Mock(PlastronCommandMessage, id='incoming_test_message', job_id=job_id)
|
|
58
|
+
|
|
59
|
+
# Handle the incoming message
|
|
60
|
+
handler = AsynchronousResponseHandler(mock_listener, incoming_message)
|
|
61
|
+
handler(future)
|
|
62
|
+
|
|
63
|
+
# Verify outbox/inbox handling and message sending
|
|
64
|
+
mock_listener.outbox.add.assert_called_once()
|
|
65
|
+
mock_listener.inbox.remove.assert_called_once_with(incoming_message.id)
|
|
66
|
+
|
|
67
|
+
mock_listener.broker['JOB_STATUS'].send.assert_called_once()
|
|
68
|
+
expected_msg_headers = {'PlastronJobId': job_id, 'PlastronJobError': exception_message, 'persistent': 'true'}
|
|
69
|
+
assert_sent_message(mock_listener.broker['JOB_STATUS'], PlastronErrorMessage, expected_msg_headers, '')
|
|
70
|
+
|
|
71
|
+
mock_listener.outbox.remove.assert_called_once_with(job_id)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def assert_sent_message(queue: Destination, message_class: Type[Message],
|
|
75
|
+
expected_message_headers: Dict[str, str], expected_body: str):
|
|
76
|
+
status_queue_send_args = cast(Mock, queue).send.call_args[0] # using cast, so mypy doesn't complain
|
|
77
|
+
sent_message = status_queue_send_args[0]
|
|
78
|
+
assert isinstance(sent_message, message_class)
|
|
79
|
+
TestCase().assertDictEqual(sent_message.headers, expected_message_headers)
|
|
80
|
+
assert sent_message.body == expected_body
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import time
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from plastron.stomp.inbox_watcher import InboxWatcher
|
|
8
|
+
from plastron.messaging.messages import MessageBox, PlastronMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_new_file_in_inbox():
|
|
12
|
+
with tempfile.TemporaryDirectory() as inbox_dirname:
|
|
13
|
+
with patch('plastron.stomp.listeners.CommandListener') as mock_command_listener:
|
|
14
|
+
# Mock "process_message" and use to verify that CommandListener
|
|
15
|
+
# is called (when a file is created in the inbox).
|
|
16
|
+
mock_method = mock_command_listener.process_message
|
|
17
|
+
|
|
18
|
+
with inbox_watcher(inbox_dirname, mock_command_listener):
|
|
19
|
+
create_test_file(inbox_dirname)
|
|
20
|
+
|
|
21
|
+
wait_until_called(mock_method)
|
|
22
|
+
|
|
23
|
+
mock_method.assert_called_once()
|
|
24
|
+
|
|
25
|
+
# Utility methods
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def inbox_watcher(inbox_dirname, mock_command_listener):
|
|
30
|
+
inbox = MessageBox(inbox_dirname, PlastronMessage)
|
|
31
|
+
watcher = InboxWatcher(mock_command_listener, inbox)
|
|
32
|
+
watcher.start()
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
yield
|
|
36
|
+
finally:
|
|
37
|
+
watcher.stop()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_test_file(inbox_dirname):
|
|
41
|
+
temp_file = open(os.path.join(inbox_dirname, 'test_new_file'), 'w')
|
|
42
|
+
temp_file.write("test:test")
|
|
43
|
+
temp_file.close()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def wait_until_called(mock_method, interval=0.1, timeout=5):
|
|
47
|
+
'''Polls at the given interval until either the mock_method is called
|
|
48
|
+
or the timeout occurs.'''
|
|
49
|
+
# Inspired by https://stackoverflow.com/a/36040926
|
|
50
|
+
start = time.time()
|
|
51
|
+
while not mock_method.called and time.time() - start < timeout:
|
|
52
|
+
time.sleep(interval)
|