osp-provider-runtime 0.1.0__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.
Potentially problematic release.
This version of osp-provider-runtime might be problematic. Click here for more details.
- osp_provider_runtime-0.1.0/.github/workflows/ci.yml +25 -0
- osp_provider_runtime-0.1.0/.github/workflows/release-artifacts.yml +32 -0
- osp_provider_runtime-0.1.0/.gitignore +9 -0
- osp_provider_runtime-0.1.0/CHANGELOG.md +8 -0
- osp_provider_runtime-0.1.0/LICENSE +21 -0
- osp_provider_runtime-0.1.0/PKG-INFO +78 -0
- osp_provider_runtime-0.1.0/README.md +52 -0
- osp_provider_runtime-0.1.0/docs/release.md +36 -0
- osp_provider_runtime-0.1.0/docs/runtime-contract.md +52 -0
- osp_provider_runtime-0.1.0/pyproject.toml +85 -0
- osp_provider_runtime-0.1.0/src/osp_provider_runtime/__init__.py +12 -0
- osp_provider_runtime-0.1.0/src/osp_provider_runtime/app.py +17 -0
- osp_provider_runtime-0.1.0/src/osp_provider_runtime/config.py +36 -0
- osp_provider_runtime-0.1.0/src/osp_provider_runtime/envelope.py +115 -0
- osp_provider_runtime-0.1.0/src/osp_provider_runtime/py.typed +0 -0
- osp_provider_runtime-0.1.0/src/osp_provider_runtime/rabbitmq.py +83 -0
- osp_provider_runtime-0.1.0/src/osp_provider_runtime/runtime.py +217 -0
- osp_provider_runtime-0.1.0/src/osp_provider_runtime/transport.py +34 -0
- osp_provider_runtime-0.1.0/src/osp_provider_runtime/version.py +1 -0
- osp_provider_runtime-0.1.0/tests/test_envelope.py +64 -0
- osp_provider_runtime-0.1.0/tests/test_runtime.py +150 -0
- osp_provider_runtime-0.1.0/uv.lock +1046 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v6
|
|
14
|
+
- name: Sync dev dependencies
|
|
15
|
+
run: uv sync --extra dev
|
|
16
|
+
- name: Ruff
|
|
17
|
+
run: uv run ruff check .
|
|
18
|
+
- name: Mypy
|
|
19
|
+
run: uv run mypy src tests
|
|
20
|
+
- name: Pytest
|
|
21
|
+
run: uv run pytest
|
|
22
|
+
- name: Build artifacts
|
|
23
|
+
run: uv run python -m build
|
|
24
|
+
- name: Validate artifacts
|
|
25
|
+
run: uv run twine check dist/*
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Release Artifacts
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build-and-attach:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v6
|
|
17
|
+
- name: Sync dev dependencies
|
|
18
|
+
run: uv sync --extra dev
|
|
19
|
+
- name: Verify quality gates
|
|
20
|
+
run: |
|
|
21
|
+
uv run ruff check .
|
|
22
|
+
uv run mypy src tests
|
|
23
|
+
uv run pytest
|
|
24
|
+
- name: Build artifacts
|
|
25
|
+
run: uv run python -m build
|
|
26
|
+
- name: Validate artifacts
|
|
27
|
+
run: uv run twine check dist/*
|
|
28
|
+
- name: Create GitHub release and upload artifacts
|
|
29
|
+
uses: softprops/action-gh-release@v2
|
|
30
|
+
with:
|
|
31
|
+
files: dist/*
|
|
32
|
+
generate_release_notes: true
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-02-12
|
|
4
|
+
|
|
5
|
+
- Initial alpha runtime package.
|
|
6
|
+
- Added thin provider execution runtime and RabbitMQ runner.
|
|
7
|
+
- Added envelope parsing/serialization and explicit ack decision model.
|
|
8
|
+
- Added tests for envelope validation and retry/ack semantics.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 IT-OPS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: osp-provider-runtime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Thin runtime harness for OSP providers (RabbitMQ transport + contract execution).
|
|
5
|
+
Author: OSP Team
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Requires-Dist: loguru<1,>=0.7
|
|
16
|
+
Requires-Dist: osp-provider-contracts<0.2,>=0.1
|
|
17
|
+
Requires-Dist: pika<2,>=1.3
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build<2,>=1.2; extra == 'dev'
|
|
20
|
+
Requires-Dist: hatch<2,>=1.14; extra == 'dev'
|
|
21
|
+
Requires-Dist: mypy<2,>=1.11; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest<9,>=8.3; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff<1,>=0.6; extra == 'dev'
|
|
24
|
+
Requires-Dist: twine<7,>=6; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# osp-provider-runtime
|
|
28
|
+
|
|
29
|
+
Thin, boring runtime harness for OSP providers.
|
|
30
|
+
|
|
31
|
+
This package handles RabbitMQ message plumbing so provider implementations can
|
|
32
|
+
focus on business logic.
|
|
33
|
+
|
|
34
|
+
## What it does (v0.1)
|
|
35
|
+
|
|
36
|
+
- Parses a versioned request envelope.
|
|
37
|
+
- Builds provider `RequestContext`/`ProviderRequest` and calls `execute(...)`.
|
|
38
|
+
- Serializes a standard response envelope.
|
|
39
|
+
- Applies explicit ack/requeue/dead-letter decisions.
|
|
40
|
+
- Emits structured logs for delivery decisions.
|
|
41
|
+
|
|
42
|
+
## What it does not do
|
|
43
|
+
|
|
44
|
+
- No provider framework.
|
|
45
|
+
- No plugin system.
|
|
46
|
+
- No workflow orchestration.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install osp-provider-runtime
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Development
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
env -u VIRTUAL_ENV uv sync --extra dev
|
|
58
|
+
hatch shell
|
|
59
|
+
hatch run check
|
|
60
|
+
hatch run build
|
|
61
|
+
hatch run verify
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Note: before `osp-provider-contracts` is published to your index, use `uv` for
|
|
65
|
+
local checks in this monorepo:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
env -u VIRTUAL_ENV uv run ruff check .
|
|
69
|
+
env -u VIRTUAL_ENV uv run mypy src tests
|
|
70
|
+
env -u VIRTUAL_ENV uv run pytest
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Tag and push:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git tag v0.1.0
|
|
77
|
+
git push origin v0.1.0
|
|
78
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# osp-provider-runtime
|
|
2
|
+
|
|
3
|
+
Thin, boring runtime harness for OSP providers.
|
|
4
|
+
|
|
5
|
+
This package handles RabbitMQ message plumbing so provider implementations can
|
|
6
|
+
focus on business logic.
|
|
7
|
+
|
|
8
|
+
## What it does (v0.1)
|
|
9
|
+
|
|
10
|
+
- Parses a versioned request envelope.
|
|
11
|
+
- Builds provider `RequestContext`/`ProviderRequest` and calls `execute(...)`.
|
|
12
|
+
- Serializes a standard response envelope.
|
|
13
|
+
- Applies explicit ack/requeue/dead-letter decisions.
|
|
14
|
+
- Emits structured logs for delivery decisions.
|
|
15
|
+
|
|
16
|
+
## What it does not do
|
|
17
|
+
|
|
18
|
+
- No provider framework.
|
|
19
|
+
- No plugin system.
|
|
20
|
+
- No workflow orchestration.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install osp-provider-runtime
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Development
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
env -u VIRTUAL_ENV uv sync --extra dev
|
|
32
|
+
hatch shell
|
|
33
|
+
hatch run check
|
|
34
|
+
hatch run build
|
|
35
|
+
hatch run verify
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Note: before `osp-provider-contracts` is published to your index, use `uv` for
|
|
39
|
+
local checks in this monorepo:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
env -u VIRTUAL_ENV uv run ruff check .
|
|
43
|
+
env -u VIRTUAL_ENV uv run mypy src tests
|
|
44
|
+
env -u VIRTUAL_ENV uv run pytest
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Tag and push:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git tag v0.1.0
|
|
51
|
+
git push origin v0.1.0
|
|
52
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Release Guide
|
|
2
|
+
|
|
3
|
+
Automated on tag `vX.Y.Z`:
|
|
4
|
+
- run lint, type-check, tests
|
|
5
|
+
- build sdist + wheel
|
|
6
|
+
- run `twine check dist/*`
|
|
7
|
+
- attach artifacts to GitHub Release
|
|
8
|
+
|
|
9
|
+
Manual/gated publish:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
env -u VIRTUAL_ENV uv sync --extra dev
|
|
13
|
+
hatch shell
|
|
14
|
+
hatch run check
|
|
15
|
+
hatch run build
|
|
16
|
+
hatch run verify
|
|
17
|
+
hatch run publish
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Pre-publish note:
|
|
21
|
+
- `osp-provider-runtime` depends on `osp-provider-contracts`.
|
|
22
|
+
- Publish contracts first (or use local `uv` checks in monorepo before publish).
|
|
23
|
+
|
|
24
|
+
For internal index or other repository:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
hatch publish -r <repo-name>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Credentials should be stored in `~/.pypirc` (or keyring), not committed.
|
|
31
|
+
|
|
32
|
+
TestPyPI dry run publish:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
hatch run publish-test
|
|
36
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Runtime Messaging Contract (v0.1)
|
|
2
|
+
|
|
3
|
+
Request envelope required fields:
|
|
4
|
+
- `envelope_version` (string)
|
|
5
|
+
- `contract_version` (string)
|
|
6
|
+
- `message_id` (string)
|
|
7
|
+
- `correlation_id` (string)
|
|
8
|
+
- `task_id` (string)
|
|
9
|
+
- `action` (string)
|
|
10
|
+
- `resource_kind` (string)
|
|
11
|
+
- `payload` (object)
|
|
12
|
+
|
|
13
|
+
Optional fields:
|
|
14
|
+
- `attempt` (int, defaults to 1)
|
|
15
|
+
- `idempotency_key` (string)
|
|
16
|
+
- `provider` (string)
|
|
17
|
+
|
|
18
|
+
Response envelope:
|
|
19
|
+
- `envelope_version`
|
|
20
|
+
- `contract_version`
|
|
21
|
+
- `message_id`
|
|
22
|
+
- `correlation_id`
|
|
23
|
+
- `task_id`
|
|
24
|
+
- `ok` (bool)
|
|
25
|
+
- `provider` (string)
|
|
26
|
+
- `action` (string)
|
|
27
|
+
- `result` (object, only when `ok=true`)
|
|
28
|
+
- `error` (object, only when `ok=false`)
|
|
29
|
+
|
|
30
|
+
Error object shape:
|
|
31
|
+
- `code` (string)
|
|
32
|
+
- `detail` (string | null)
|
|
33
|
+
- `retryable` (bool)
|
|
34
|
+
- `extra` (object)
|
|
35
|
+
|
|
36
|
+
Ack policy:
|
|
37
|
+
- Success: `ack`.
|
|
38
|
+
- `ProviderError(retryable=false)`: `ack`.
|
|
39
|
+
- `ProviderError(retryable=true)`: `requeue` until max attempts; then dead-letter.
|
|
40
|
+
- Unknown exception: treat as retryable transient error with same attempt policy.
|
|
41
|
+
|
|
42
|
+
Legacy compatibility:
|
|
43
|
+
- Runtime also accepts legacy orchestrator outbox payloads:
|
|
44
|
+
- `{"id": <int>, "action": <str>, ...}`
|
|
45
|
+
- For legacy input, runtime can emit provider update events to `osp.to_orch`
|
|
46
|
+
using `status_code=200` on success and `status_code=580` on terminal errors.
|
|
47
|
+
|
|
48
|
+
Runtime topology behavior:
|
|
49
|
+
- Runtime declares and binds its request queue on startup.
|
|
50
|
+
- Default request exchange: `osp.from_orch` (topic).
|
|
51
|
+
- Default request binding: `<provider_name>.#`.
|
|
52
|
+
- Default updates exchange: `osp.to_orch` (topic).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "osp-provider-runtime"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Thin runtime harness for OSP providers (RabbitMQ transport + contract execution)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "OSP Team" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"loguru>=0.7,<1",
|
|
15
|
+
"osp-provider-contracts>=0.1,<0.2",
|
|
16
|
+
"pika>=1.3,<2",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"build>=1.2,<2",
|
|
30
|
+
"hatch>=1.14,<2",
|
|
31
|
+
"mypy>=1.11,<2",
|
|
32
|
+
"pytest>=8.3,<9",
|
|
33
|
+
"ruff>=0.6,<1",
|
|
34
|
+
"twine>=6,<7",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/osp_provider_runtime"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
target-version = "py312"
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
addopts = "-q"
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
python_version = "3.12"
|
|
53
|
+
warn_return_any = true
|
|
54
|
+
warn_unused_configs = true
|
|
55
|
+
disallow_untyped_defs = true
|
|
56
|
+
no_implicit_optional = true
|
|
57
|
+
strict_equality = true
|
|
58
|
+
|
|
59
|
+
[[tool.mypy.overrides]]
|
|
60
|
+
module = "pika"
|
|
61
|
+
ignore_missing_imports = true
|
|
62
|
+
|
|
63
|
+
[[tool.mypy.overrides]]
|
|
64
|
+
module = "pika.*"
|
|
65
|
+
ignore_missing_imports = true
|
|
66
|
+
|
|
67
|
+
[tool.hatch.envs.default]
|
|
68
|
+
python = "3.12"
|
|
69
|
+
installer = "uv"
|
|
70
|
+
path = ".venv"
|
|
71
|
+
features = ["dev"]
|
|
72
|
+
|
|
73
|
+
[tool.hatch.envs.default.scripts]
|
|
74
|
+
check = [
|
|
75
|
+
"ruff check .",
|
|
76
|
+
"mypy src tests",
|
|
77
|
+
"pytest",
|
|
78
|
+
]
|
|
79
|
+
build = "python -m build"
|
|
80
|
+
verify = "twine check dist/*"
|
|
81
|
+
publish-test = "twine upload -r testpypi dist/*"
|
|
82
|
+
publish = "twine upload dist/*"
|
|
83
|
+
|
|
84
|
+
[tool.uv.sources]
|
|
85
|
+
osp-provider-contracts = { path = "../osp-provider-contracts", editable = true }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .app import run_provider, run_provider_with_config
|
|
2
|
+
from .config import RuntimeConfig
|
|
3
|
+
from .runtime import ProviderRuntime
|
|
4
|
+
from .version import __version__
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"__version__",
|
|
8
|
+
"ProviderRuntime",
|
|
9
|
+
"RuntimeConfig",
|
|
10
|
+
"run_provider",
|
|
11
|
+
"run_provider_with_config",
|
|
12
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from osp_provider_contracts import Provider
|
|
4
|
+
|
|
5
|
+
from .config import RuntimeConfig
|
|
6
|
+
from .rabbitmq import RabbitMQRunner
|
|
7
|
+
from .runtime import ProviderRuntime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_provider(provider: Provider) -> None:
|
|
11
|
+
run_provider_with_config(provider, RuntimeConfig.from_env())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_provider_with_config(provider: Provider, config: RuntimeConfig) -> None:
|
|
15
|
+
runtime = ProviderRuntime(provider=provider, config=config)
|
|
16
|
+
runner = RabbitMQRunner(config)
|
|
17
|
+
runner.run(runtime.handle_delivery)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class RuntimeConfig:
|
|
9
|
+
rabbitmq_url: str
|
|
10
|
+
request_queue: str
|
|
11
|
+
request_exchange: str = "osp.from_orch"
|
|
12
|
+
request_binding: str | None = None
|
|
13
|
+
prefetch_count: int = 1
|
|
14
|
+
max_attempts: int = 5
|
|
15
|
+
provider_name: str = "unknown-provider"
|
|
16
|
+
updates_exchange: str = "osp.to_orch"
|
|
17
|
+
updates_routing_key: str = "unknown-provider"
|
|
18
|
+
emit_legacy_updates: bool = True
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_env(cls) -> RuntimeConfig:
|
|
22
|
+
provider_name = os.getenv("OSP_RUNTIME_PROVIDER_NAME", "unknown-provider")
|
|
23
|
+
request_binding = os.getenv("OSP_RUNTIME_REQUEST_BINDING") or f"{provider_name}.#"
|
|
24
|
+
return cls(
|
|
25
|
+
rabbitmq_url=os.environ["OSP_RUNTIME_RABBITMQ_URL"],
|
|
26
|
+
request_queue=os.environ["OSP_RUNTIME_REQUEST_QUEUE"],
|
|
27
|
+
request_exchange=os.getenv("OSP_RUNTIME_REQUEST_EXCHANGE", "osp.from_orch"),
|
|
28
|
+
request_binding=request_binding,
|
|
29
|
+
prefetch_count=int(os.getenv("OSP_RUNTIME_PREFETCH_COUNT", "1")),
|
|
30
|
+
max_attempts=int(os.getenv("OSP_RUNTIME_MAX_ATTEMPTS", "5")),
|
|
31
|
+
provider_name=provider_name,
|
|
32
|
+
updates_exchange=os.getenv("OSP_RUNTIME_UPDATES_EXCHANGE", "osp.to_orch"),
|
|
33
|
+
updates_routing_key=os.getenv("OSP_RUNTIME_UPDATES_ROUTING_KEY", "unknown-provider"),
|
|
34
|
+
emit_legacy_updates=os.getenv("OSP_RUNTIME_EMIT_LEGACY_UPDATES", "1").lower()
|
|
35
|
+
in {"1", "true", "yes", "y"},
|
|
36
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class RequestEnvelope:
|
|
10
|
+
envelope_version: str
|
|
11
|
+
contract_version: str
|
|
12
|
+
message_id: str
|
|
13
|
+
correlation_id: str
|
|
14
|
+
task_id: str
|
|
15
|
+
action: str
|
|
16
|
+
resource_kind: str
|
|
17
|
+
payload: dict[str, Any]
|
|
18
|
+
attempt: int = 1
|
|
19
|
+
idempotency_key: str | None = None
|
|
20
|
+
provider: str | None = None
|
|
21
|
+
source_format: str = "contract_v1"
|
|
22
|
+
legacy_task_id: int | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class ResponseEnvelope:
|
|
27
|
+
envelope_version: str
|
|
28
|
+
contract_version: str
|
|
29
|
+
message_id: str
|
|
30
|
+
correlation_id: str
|
|
31
|
+
task_id: str
|
|
32
|
+
ok: bool
|
|
33
|
+
provider: str
|
|
34
|
+
action: str
|
|
35
|
+
result: dict[str, Any] | None = None
|
|
36
|
+
error: dict[str, Any] | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
REQUIRED_REQUEST_FIELDS = {
|
|
40
|
+
"envelope_version",
|
|
41
|
+
"contract_version",
|
|
42
|
+
"message_id",
|
|
43
|
+
"correlation_id",
|
|
44
|
+
"task_id",
|
|
45
|
+
"action",
|
|
46
|
+
"resource_kind",
|
|
47
|
+
"payload",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_request_envelope(raw_body: bytes) -> RequestEnvelope:
|
|
52
|
+
data = json.loads(raw_body.decode("utf-8"))
|
|
53
|
+
|
|
54
|
+
if not isinstance(data, dict):
|
|
55
|
+
raise ValueError("request envelope must be a JSON object")
|
|
56
|
+
|
|
57
|
+
missing = sorted(REQUIRED_REQUEST_FIELDS - set(data.keys()))
|
|
58
|
+
if not missing:
|
|
59
|
+
payload = data["payload"]
|
|
60
|
+
if not isinstance(payload, dict):
|
|
61
|
+
raise ValueError("payload must be a JSON object")
|
|
62
|
+
|
|
63
|
+
attempt = data.get("attempt", 1)
|
|
64
|
+
if not isinstance(attempt, int) or attempt < 1:
|
|
65
|
+
raise ValueError("attempt must be an integer >= 1")
|
|
66
|
+
|
|
67
|
+
return RequestEnvelope(
|
|
68
|
+
envelope_version=str(data["envelope_version"]),
|
|
69
|
+
contract_version=str(data["contract_version"]),
|
|
70
|
+
message_id=str(data["message_id"]),
|
|
71
|
+
correlation_id=str(data["correlation_id"]),
|
|
72
|
+
task_id=str(data["task_id"]),
|
|
73
|
+
action=str(data["action"]),
|
|
74
|
+
resource_kind=str(data["resource_kind"]),
|
|
75
|
+
payload=payload,
|
|
76
|
+
attempt=attempt,
|
|
77
|
+
idempotency_key=(str(data["idempotency_key"]) if "idempotency_key" in data else None),
|
|
78
|
+
provider=(str(data["provider"]) if "provider" in data else None),
|
|
79
|
+
source_format="contract_v1",
|
|
80
|
+
legacy_task_id=None,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Legacy orchestrator outbox payload:
|
|
84
|
+
# {"id": int, "action": str, ...} with provider-specific fields at top level.
|
|
85
|
+
if "id" not in data or "action" not in data:
|
|
86
|
+
raise ValueError(f"missing required envelope fields: {missing}")
|
|
87
|
+
task_id = data.get("id")
|
|
88
|
+
action = data.get("action")
|
|
89
|
+
if not isinstance(task_id, int) or not isinstance(action, str) or not action.strip():
|
|
90
|
+
raise ValueError("legacy message requires id:int and action:str")
|
|
91
|
+
attempt = data.get("attempt", 1)
|
|
92
|
+
if not isinstance(attempt, int) or attempt < 1:
|
|
93
|
+
raise ValueError("attempt must be an integer >= 1")
|
|
94
|
+
provider = data.get("provider")
|
|
95
|
+
message_id = str(data.get("message_id") or f"legacy-task-{task_id}")
|
|
96
|
+
correlation_id = str(data.get("correlation_id") or message_id)
|
|
97
|
+
return RequestEnvelope(
|
|
98
|
+
envelope_version="legacy_v1",
|
|
99
|
+
contract_version="legacy_v1",
|
|
100
|
+
message_id=message_id,
|
|
101
|
+
correlation_id=correlation_id,
|
|
102
|
+
task_id=str(task_id),
|
|
103
|
+
action=action.strip(),
|
|
104
|
+
resource_kind=str(data.get("resource_kind") or "task"),
|
|
105
|
+
payload=data,
|
|
106
|
+
attempt=attempt,
|
|
107
|
+
idempotency_key=(str(data["idempotency_key"]) if "idempotency_key" in data else None),
|
|
108
|
+
provider=(str(provider) if provider is not None else None),
|
|
109
|
+
source_format="legacy_orchestrator",
|
|
110
|
+
legacy_task_id=task_id,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def dump_response_envelope(envelope: ResponseEnvelope) -> bytes:
|
|
115
|
+
return json.dumps(asdict(envelope), separators=(",", ":")).encode("utf-8")
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pika # type: ignore[import-untyped]
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from .config import RuntimeConfig
|
|
9
|
+
from .transport import AckAction, Delivery, DeliveryHandler
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RabbitMQRunner:
|
|
13
|
+
def __init__(self, config: RuntimeConfig) -> None:
|
|
14
|
+
self.config = config
|
|
15
|
+
|
|
16
|
+
def run(self, handler: DeliveryHandler) -> None:
|
|
17
|
+
params = pika.URLParameters(self.config.rabbitmq_url)
|
|
18
|
+
connection = pika.BlockingConnection(params)
|
|
19
|
+
channel = connection.channel()
|
|
20
|
+
channel.basic_qos(prefetch_count=self.config.prefetch_count)
|
|
21
|
+
channel.exchange_declare(
|
|
22
|
+
exchange=self.config.request_exchange,
|
|
23
|
+
exchange_type="topic",
|
|
24
|
+
durable=True,
|
|
25
|
+
)
|
|
26
|
+
channel.exchange_declare(
|
|
27
|
+
exchange=self.config.updates_exchange,
|
|
28
|
+
exchange_type="topic",
|
|
29
|
+
durable=True,
|
|
30
|
+
)
|
|
31
|
+
channel.queue_declare(queue=self.config.request_queue, durable=True)
|
|
32
|
+
if self.config.request_binding:
|
|
33
|
+
channel.queue_bind(
|
|
34
|
+
queue=self.config.request_queue,
|
|
35
|
+
exchange=self.config.request_exchange,
|
|
36
|
+
routing_key=self.config.request_binding,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def _on_message(ch: Any, method: Any, properties: Any, body: bytes) -> None:
|
|
40
|
+
delivery = Delivery(
|
|
41
|
+
body=body,
|
|
42
|
+
reply_to=properties.reply_to,
|
|
43
|
+
routing_key=getattr(method, "routing_key", None),
|
|
44
|
+
)
|
|
45
|
+
result = handler(delivery)
|
|
46
|
+
|
|
47
|
+
if result.response_body and result.response_routing_key:
|
|
48
|
+
channel.basic_publish(
|
|
49
|
+
exchange="",
|
|
50
|
+
routing_key=result.response_routing_key,
|
|
51
|
+
body=result.response_body,
|
|
52
|
+
properties=pika.BasicProperties(
|
|
53
|
+
content_type="application/json",
|
|
54
|
+
correlation_id=result.response_correlation_id,
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
if result.update_body and result.update_exchange and result.update_routing_key:
|
|
58
|
+
channel.basic_publish(
|
|
59
|
+
exchange=result.update_exchange,
|
|
60
|
+
routing_key=result.update_routing_key,
|
|
61
|
+
body=result.update_body,
|
|
62
|
+
properties=pika.BasicProperties(
|
|
63
|
+
content_type="application/json",
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if result.ack_action == AckAction.ACK:
|
|
68
|
+
ch.basic_ack(delivery_tag=method.delivery_tag)
|
|
69
|
+
elif result.ack_action == AckAction.REQUEUE:
|
|
70
|
+
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
|
|
71
|
+
else:
|
|
72
|
+
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
|
|
73
|
+
|
|
74
|
+
channel.basic_consume(queue=self.config.request_queue, on_message_callback=_on_message)
|
|
75
|
+
logger.info(
|
|
76
|
+
"Starting runtime consumer",
|
|
77
|
+
queue=self.config.request_queue,
|
|
78
|
+
exchange=self.config.request_exchange,
|
|
79
|
+
binding=self.config.request_binding,
|
|
80
|
+
updates_exchange=self.config.updates_exchange,
|
|
81
|
+
updates_routing_key=self.config.updates_routing_key,
|
|
82
|
+
)
|
|
83
|
+
channel.start_consuming()
|