python-plugin 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.
- python_plugin-0.1.0/.github/workflows/publish.yml +57 -0
- python_plugin-0.1.0/.gitignore +12 -0
- python_plugin-0.1.0/LICENSE +21 -0
- python_plugin-0.1.0/PKG-INFO +254 -0
- python_plugin-0.1.0/README.md +217 -0
- python_plugin-0.1.0/examples/greeter/README.md +81 -0
- python_plugin-0.1.0/examples/greeter/__init__.py +0 -0
- python_plugin-0.1.0/examples/greeter/generated/__init__.py +0 -0
- python_plugin-0.1.0/examples/greeter/generated/greeter_grpc.py +56 -0
- python_plugin-0.1.0/examples/greeter/generated/greeter_pb2.py +44 -0
- python_plugin-0.1.0/examples/greeter/host.py +55 -0
- python_plugin-0.1.0/examples/greeter/plugin.py +31 -0
- python_plugin-0.1.0/examples/greeter/proto/greeter.proto +25 -0
- python_plugin-0.1.0/examples/greeter/shared.py +52 -0
- python_plugin-0.1.0/fixtures/example_kv/__init__.py +0 -0
- python_plugin-0.1.0/fixtures/example_kv/generated/__init__.py +0 -0
- python_plugin-0.1.0/fixtures/example_kv/generated/callback_grpc.py +40 -0
- python_plugin-0.1.0/fixtures/example_kv/generated/callback_pb2.py +40 -0
- python_plugin-0.1.0/fixtures/example_kv/plugin_main.py +23 -0
- python_plugin-0.1.0/fixtures/example_kv/plugin_main_for_go_host.py +28 -0
- python_plugin-0.1.0/fixtures/example_kv/plugin_main_stubborn.py +30 -0
- python_plugin-0.1.0/fixtures/example_kv/plugin_main_tcp.py +24 -0
- python_plugin-0.1.0/fixtures/example_kv/plugin_main_versioned.py +31 -0
- python_plugin-0.1.0/fixtures/example_kv/plugin_main_zombie.py +33 -0
- python_plugin-0.1.0/fixtures/example_kv/proto/callback.proto +11 -0
- python_plugin-0.1.0/fixtures/example_kv/proto/kv.proto +17 -0
- python_plugin-0.1.0/pyproject.toml +61 -0
- python_plugin-0.1.0/scripts/gen_protos.py +68 -0
- python_plugin-0.1.0/src/pyplugin/__init__.py +54 -0
- python_plugin-0.1.0/src/pyplugin/_generated/__init__.py +0 -0
- python_plugin-0.1.0/src/pyplugin/_generated/grpc_broker_grpc.py +40 -0
- python_plugin-0.1.0/src/pyplugin/_generated/grpc_broker_pb2.py +41 -0
- python_plugin-0.1.0/src/pyplugin/_generated/grpc_controller_grpc.py +40 -0
- python_plugin-0.1.0/src/pyplugin/_generated/grpc_controller_pb2.py +39 -0
- python_plugin-0.1.0/src/pyplugin/_generated/grpc_stdio_grpc.py +41 -0
- python_plugin-0.1.0/src/pyplugin/_generated/grpc_stdio_pb2.py +42 -0
- python_plugin-0.1.0/src/pyplugin/broker.py +225 -0
- python_plugin-0.1.0/src/pyplugin/client.py +399 -0
- python_plugin-0.1.0/src/pyplugin/controller.py +22 -0
- python_plugin-0.1.0/src/pyplugin/cookie.py +43 -0
- python_plugin-0.1.0/src/pyplugin/errors.py +39 -0
- python_plugin-0.1.0/src/pyplugin/handshake.py +121 -0
- python_plugin-0.1.0/src/pyplugin/health.py +38 -0
- python_plugin-0.1.0/src/pyplugin/logging_bridge.py +70 -0
- python_plugin-0.1.0/src/pyplugin/mtls.py +169 -0
- python_plugin-0.1.0/src/pyplugin/plugin.py +38 -0
- python_plugin-0.1.0/src/pyplugin/process.py +66 -0
- python_plugin-0.1.0/src/pyplugin/proto/grpc_broker.proto +21 -0
- python_plugin-0.1.0/src/pyplugin/proto/grpc_controller.proto +12 -0
- python_plugin-0.1.0/src/pyplugin/proto/grpc_stdio.proto +22 -0
- python_plugin-0.1.0/src/pyplugin/reattach.py +27 -0
- python_plugin-0.1.0/src/pyplugin/server.py +204 -0
- python_plugin-0.1.0/src/pyplugin/stdio.py +36 -0
- python_plugin-0.1.0/src/pyplugin/transport.py +103 -0
- python_plugin-0.1.0/tests/conftest.py +9 -0
- python_plugin-0.1.0/tests/interop/__init__.py +0 -0
- python_plugin-0.1.0/tests/interop/generated/__init__.py +0 -0
- python_plugin-0.1.0/tests/interop/test_go_host_drives_python_plugin.py +42 -0
- python_plugin-0.1.0/tests/interop/test_python_host_drives_go_plugin.py +84 -0
- python_plugin-0.1.0/tests/test_broker.py +61 -0
- python_plugin-0.1.0/tests/test_cookie.py +34 -0
- python_plugin-0.1.0/tests/test_handshake.py +92 -0
- python_plugin-0.1.0/tests/test_logging_bridge.py +58 -0
- python_plugin-0.1.0/tests/test_mtls.py +56 -0
- python_plugin-0.1.0/tests/test_reattach.py +54 -0
- python_plugin-0.1.0/tests/test_shutdown.py +67 -0
- python_plugin-0.1.0/tests/test_smoke.py +62 -0
- python_plugin-0.1.0/tests/test_smoke_tcp.py +42 -0
- python_plugin-0.1.0/tests/test_versioning.py +58 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
name: Build sdist + wheel
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
- name: Install build
|
|
18
|
+
run: python -m pip install --upgrade build
|
|
19
|
+
- name: Build distributions
|
|
20
|
+
run: python -m build
|
|
21
|
+
- name: Upload dist artifact
|
|
22
|
+
uses: actions/upload-artifact@v4
|
|
23
|
+
with:
|
|
24
|
+
name: dist
|
|
25
|
+
path: dist/
|
|
26
|
+
|
|
27
|
+
publish-testpypi:
|
|
28
|
+
name: Publish to TestPyPI
|
|
29
|
+
needs: build
|
|
30
|
+
if: contains(github.ref_name, 'rc') || contains(github.ref_name, 'a') || contains(github.ref_name, 'b')
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
environment: testpypi
|
|
33
|
+
permissions:
|
|
34
|
+
id-token: write
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/download-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
41
|
+
with:
|
|
42
|
+
repository-url: https://test.pypi.org/legacy/
|
|
43
|
+
|
|
44
|
+
publish-pypi:
|
|
45
|
+
name: Publish to PyPI
|
|
46
|
+
needs: build
|
|
47
|
+
if: ${{ !contains(github.ref_name, 'rc') && !contains(github.ref_name, 'a') && !contains(github.ref_name, 'b') }}
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
environment: pypi
|
|
50
|
+
permissions:
|
|
51
|
+
id-token: write
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/download-artifact@v4
|
|
54
|
+
with:
|
|
55
|
+
name: dist
|
|
56
|
+
path: dist/
|
|
57
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Max Lund
|
|
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,254 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-plugin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Wire-compatible Python port of go-plugin (gRPC subprocess plugins with AutoMTLS)
|
|
5
|
+
Project-URL: Homepage, https://github.com/mlund01/py-plugin
|
|
6
|
+
Project-URL: Repository, https://github.com/mlund01/py-plugin
|
|
7
|
+
Project-URL: Issues, https://github.com/mlund01/py-plugin/issues
|
|
8
|
+
Author: Max Lund
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: go-plugin,grpc,ipc,mtls,plugin,rpc
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Programming Language :: Python
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: cryptography>=41
|
|
29
|
+
Requires-Dist: grpclib[protobuf]>=0.4.7
|
|
30
|
+
Requires-Dist: protobuf>=4.25
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: grpcio-tools>=1.60; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-timeout>=2.2; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# pyplugin
|
|
39
|
+
|
|
40
|
+
A Python port of [`go-plugin`](https://github.com/hashicorp/go-plugin),
|
|
41
|
+
**byte-for-byte wire-compatible** with the original — including AutoMTLS
|
|
42
|
+
with ECDSA P-521 ephemeral certs.
|
|
43
|
+
|
|
44
|
+
A Python host can launch a Go plugin built with go-plugin, and a Go host
|
|
45
|
+
built against go-plugin can launch a Python plugin built with pyplugin.
|
|
46
|
+
**Both directions, with or without AutoMTLS.** Verified against the
|
|
47
|
+
upstream `examples/grpc/plugin-go-grpc` binary in the test suite.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install python-plugin
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The PyPI distribution name is `python-plugin`; the Python import name is
|
|
56
|
+
`pyplugin` (`from pyplugin import Client, serve, ...`).
|
|
57
|
+
|
|
58
|
+
## Why grpclib (and async)
|
|
59
|
+
|
|
60
|
+
go-plugin generates **ECDSA P-521** ephemeral certificates for AutoMTLS.
|
|
61
|
+
`grpcio` is built on BoringSSL, which deliberately omits P-521 from its
|
|
62
|
+
TLS signature algorithm list — there's no way to configure it back in,
|
|
63
|
+
and we want exact wire-compat. `grpclib` is a pure-Python gRPC library
|
|
64
|
+
on top of Python's `ssl` module (OpenSSL), which supports P-521 freely.
|
|
65
|
+
That makes interop with stock go-plugin work out of the box.
|
|
66
|
+
|
|
67
|
+
The cost: the public API is **async**. Plugin servicers are `async def`;
|
|
68
|
+
host code uses `async with Client(...) as c: await c.start()` etc.
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
A complete runnable example lives in [`examples/greeter/`](examples/greeter/) —
|
|
73
|
+
clone the repo, `pip install -e '.[dev]'`, then:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python examples/greeter/host.py "ada" # insecure
|
|
77
|
+
AUTO_MTLS=1 python examples/greeter/host.py "ada" # P-521 mTLS
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Plugin (async)
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# my_plugin.py
|
|
84
|
+
from pyplugin import HandshakeConfig, Plugin, ServeConfig, serve
|
|
85
|
+
from grpclib.client import Channel
|
|
86
|
+
|
|
87
|
+
# stubs generated by grpclib's protoc plugin (see scripts/gen_protos.py):
|
|
88
|
+
import myservice_grpc, myservice_pb2
|
|
89
|
+
|
|
90
|
+
class MyServicer(myservice_grpc.MyServiceBase):
|
|
91
|
+
async def Greet(self, stream):
|
|
92
|
+
request = await stream.recv_message()
|
|
93
|
+
await stream.send_message(myservice_pb2.GreetResponse(message=f"hello {request.name}"))
|
|
94
|
+
|
|
95
|
+
class MyPlugin(Plugin):
|
|
96
|
+
def servicers(self, broker):
|
|
97
|
+
return [MyServicer()]
|
|
98
|
+
def stub(self, broker, channel: Channel):
|
|
99
|
+
return myservice_grpc.MyServiceStub(channel)
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
serve(ServeConfig(
|
|
103
|
+
handshake_config=HandshakeConfig(
|
|
104
|
+
protocol_version=1,
|
|
105
|
+
magic_cookie_key="MYPLUGIN_COOKIE",
|
|
106
|
+
magic_cookie_value="hello",
|
|
107
|
+
),
|
|
108
|
+
plugins={"my": MyPlugin()},
|
|
109
|
+
))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Host (async)
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
import asyncio, sys
|
|
116
|
+
from pyplugin import Client, ClientConfig, HandshakeConfig
|
|
117
|
+
|
|
118
|
+
async def main():
|
|
119
|
+
async with Client(ClientConfig(
|
|
120
|
+
handshake_config=HandshakeConfig(1, "MYPLUGIN_COOKIE", "hello"),
|
|
121
|
+
plugins={"my": MyPlugin()},
|
|
122
|
+
cmd=[sys.executable, "my_plugin.py"],
|
|
123
|
+
auto_mtls=True, # P-521 mTLS, fully wire-compatible with go-plugin
|
|
124
|
+
)) as client:
|
|
125
|
+
stub = client.dispense("my")
|
|
126
|
+
resp = await stub.Greet(myservice_pb2.GreetRequest(name="world"))
|
|
127
|
+
print(resp.message)
|
|
128
|
+
|
|
129
|
+
asyncio.run(main())
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## What's implemented
|
|
133
|
+
|
|
134
|
+
| Feature | Status |
|
|
135
|
+
| --- | --- |
|
|
136
|
+
| stdout handshake protocol (6/7 segments, base64.RawStdEncoding cert) | ✅ |
|
|
137
|
+
| magic cookie validation | ✅ |
|
|
138
|
+
| gRPC transport: unix sockets (POSIX) and TCP loopback | ✅ |
|
|
139
|
+
| AutoMTLS with **ECDSA P-521 / SHA-512** (matches go-plugin) | ✅ |
|
|
140
|
+
| `GRPCController.Shutdown` graceful exit | ✅ |
|
|
141
|
+
| Kill ladder: Shutdown → SIGTERM → SIGKILL | ✅ |
|
|
142
|
+
| stderr forwarding with hclog parser (JSON + pretty) | ✅ |
|
|
143
|
+
| `GRPCBroker` bidirectional sub-channels (Accept/Dial) | ✅ |
|
|
144
|
+
| `GRPCStdio` post-handshake stdout/stderr stream | ✅ |
|
|
145
|
+
| `ReattachConfig` (host re-connects to running plugin) | ✅ |
|
|
146
|
+
| `VersionedPlugins` negotiation | ✅ |
|
|
147
|
+
| gRPC reflection + health (service name `plugin`) | ✅ |
|
|
148
|
+
| `PLUGIN_MULTIPLEX_GRPC` (broker over single socket) | ❌ deferred (advertised as not supported) |
|
|
149
|
+
|
|
150
|
+
## Verified Python ↔ Go interop
|
|
151
|
+
|
|
152
|
+
The test suite includes 4 real interop tests against upstream go-plugin:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
tests/interop/test_python_host_drives_go_plugin.py
|
|
156
|
+
test_python_host_drives_go_plugin_no_mtls ✓
|
|
157
|
+
test_python_host_drives_go_plugin_with_p521_automtls ✓
|
|
158
|
+
|
|
159
|
+
tests/interop/test_go_host_drives_python_plugin.py
|
|
160
|
+
test_go_host_drives_python_plugin_no_mtls ✓
|
|
161
|
+
test_go_host_drives_python_plugin_with_p521_automtls ✓
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
These run only when the binaries are present; the README of
|
|
165
|
+
`tests/interop/` describes how to build them. Out of the box you can
|
|
166
|
+
reproduce the matrix locally:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Build go-plugin's example KV plugin (Go)
|
|
170
|
+
git clone --depth=1 https://github.com/hashicorp/go-plugin /tmp/gp
|
|
171
|
+
(cd /tmp/gp/examples/grpc && go build -o /tmp/plugin-go-grpc ./plugin-go-grpc)
|
|
172
|
+
PYPLUGIN_GO_PLUGIN_KV=/tmp/plugin-go-grpc pytest tests/interop/test_python_host_drives_go_plugin.py
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
For the Go-host-drives-Python-plugin direction, see the small Go host
|
|
176
|
+
template at the end of this README — drop it into `tests/interop/go-host/`
|
|
177
|
+
with a `replace` directive in `go.mod` pointing at the local go-plugin
|
|
178
|
+
clone, `go build`, then point `PYPLUGIN_GO_HOST_BIN` at the binary.
|
|
179
|
+
|
|
180
|
+
## Layout
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
src/pyplugin/
|
|
184
|
+
handshake.py # stdout protocol line format/parse
|
|
185
|
+
cookie.py # magic-cookie validation
|
|
186
|
+
mtls.py # ephemeral P-521 cert generation + ssl.SSLContext builders
|
|
187
|
+
transport.py # unix / tcp listener helpers
|
|
188
|
+
server.py # serve(ServeConfig) — sync entry, internal asyncio loop
|
|
189
|
+
client.py # Client / ClientConfig — async host launcher
|
|
190
|
+
process.py # cross-platform subprocess termination
|
|
191
|
+
reattach.py # ReattachConfig
|
|
192
|
+
controller.py # GRPCController.Shutdown servicer (grpclib async)
|
|
193
|
+
broker.py # GRPCBroker bidirectional multiplexer (grpclib async)
|
|
194
|
+
stdio.py # GRPCStdio post-handshake stream (grpclib async)
|
|
195
|
+
health.py # static grpc.health.v1 servicer (returns SERVING for "plugin")
|
|
196
|
+
plugin.py # Plugin ABC, PluginSet, VersionedPlugins
|
|
197
|
+
logging_bridge.py # hclog (JSON + pretty) line parser
|
|
198
|
+
errors.py # exception hierarchy
|
|
199
|
+
proto/ # vendored .proto files from go-plugin (verbatim)
|
|
200
|
+
_generated/ # checked-in grpclib stubs
|
|
201
|
+
fixtures/example_kv/ # example KV plugin used by smoke tests
|
|
202
|
+
tests/ # 40 unit + Python↔Python tests
|
|
203
|
+
tests/interop/ # 4 real-go-plugin interop tests
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
python3 -m venv .venv
|
|
210
|
+
.venv/bin/pip install -e '.[dev]'
|
|
211
|
+
.venv/bin/python scripts/gen_protos.py # regenerate stubs
|
|
212
|
+
.venv/bin/python -m pytest # run tests
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Go host template
|
|
216
|
+
|
|
217
|
+
Use this with `go.mod`'s `replace github.com/hashicorp/go-plugin => /path/to/clone`:
|
|
218
|
+
|
|
219
|
+
```go
|
|
220
|
+
package main
|
|
221
|
+
|
|
222
|
+
import (
|
|
223
|
+
"fmt"; "io"; "log"; "os"; "os/exec"
|
|
224
|
+
hclog "github.com/hashicorp/go-hclog"
|
|
225
|
+
"github.com/hashicorp/go-plugin"
|
|
226
|
+
"github.com/hashicorp/go-plugin/examples/grpc/shared"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
func main() {
|
|
230
|
+
log.SetOutput(io.Discard)
|
|
231
|
+
client := plugin.NewClient(&plugin.ClientConfig{
|
|
232
|
+
HandshakeConfig: shared.Handshake,
|
|
233
|
+
Plugins: map[string]plugin.Plugin{shared.PluginGRPC: &shared.KVGRPCPlugin{}},
|
|
234
|
+
Cmd: exec.Command(os.Args[1], os.Args[2]),
|
|
235
|
+
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
|
|
236
|
+
AutoMTLS: os.Getenv("AUTO_MTLS") == "1",
|
|
237
|
+
Logger: hclog.New(&hclog.LoggerOptions{Output: io.Discard, Level: hclog.Off}),
|
|
238
|
+
})
|
|
239
|
+
defer client.Kill()
|
|
240
|
+
rpc, err := client.Client(); if err != nil { panic(err) }
|
|
241
|
+
raw, err := rpc.Dispense(shared.PluginGRPC); if err != nil { panic(err) }
|
|
242
|
+
kv := raw.(shared.KV)
|
|
243
|
+
if err := kv.Put(os.Args[4], []byte(os.Args[5])); err != nil { panic(err) }
|
|
244
|
+
v, err := kv.Get(os.Args[4]); if err != nil { panic(err) }
|
|
245
|
+
fmt.Print(string(v))
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## License
|
|
250
|
+
|
|
251
|
+
MIT. The vendored `.proto` files in `src/pyplugin/proto/` retain their
|
|
252
|
+
upstream MPL-2.0 headers from
|
|
253
|
+
[hashicorp/go-plugin](https://github.com/hashicorp/go-plugin); MPL-2.0 is
|
|
254
|
+
file-level and is compatible with MIT for the rest of the project.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# pyplugin
|
|
2
|
+
|
|
3
|
+
A Python port of [`go-plugin`](https://github.com/hashicorp/go-plugin),
|
|
4
|
+
**byte-for-byte wire-compatible** with the original — including AutoMTLS
|
|
5
|
+
with ECDSA P-521 ephemeral certs.
|
|
6
|
+
|
|
7
|
+
A Python host can launch a Go plugin built with go-plugin, and a Go host
|
|
8
|
+
built against go-plugin can launch a Python plugin built with pyplugin.
|
|
9
|
+
**Both directions, with or without AutoMTLS.** Verified against the
|
|
10
|
+
upstream `examples/grpc/plugin-go-grpc` binary in the test suite.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install python-plugin
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The PyPI distribution name is `python-plugin`; the Python import name is
|
|
19
|
+
`pyplugin` (`from pyplugin import Client, serve, ...`).
|
|
20
|
+
|
|
21
|
+
## Why grpclib (and async)
|
|
22
|
+
|
|
23
|
+
go-plugin generates **ECDSA P-521** ephemeral certificates for AutoMTLS.
|
|
24
|
+
`grpcio` is built on BoringSSL, which deliberately omits P-521 from its
|
|
25
|
+
TLS signature algorithm list — there's no way to configure it back in,
|
|
26
|
+
and we want exact wire-compat. `grpclib` is a pure-Python gRPC library
|
|
27
|
+
on top of Python's `ssl` module (OpenSSL), which supports P-521 freely.
|
|
28
|
+
That makes interop with stock go-plugin work out of the box.
|
|
29
|
+
|
|
30
|
+
The cost: the public API is **async**. Plugin servicers are `async def`;
|
|
31
|
+
host code uses `async with Client(...) as c: await c.start()` etc.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
A complete runnable example lives in [`examples/greeter/`](examples/greeter/) —
|
|
36
|
+
clone the repo, `pip install -e '.[dev]'`, then:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python examples/greeter/host.py "ada" # insecure
|
|
40
|
+
AUTO_MTLS=1 python examples/greeter/host.py "ada" # P-521 mTLS
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Plugin (async)
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# my_plugin.py
|
|
47
|
+
from pyplugin import HandshakeConfig, Plugin, ServeConfig, serve
|
|
48
|
+
from grpclib.client import Channel
|
|
49
|
+
|
|
50
|
+
# stubs generated by grpclib's protoc plugin (see scripts/gen_protos.py):
|
|
51
|
+
import myservice_grpc, myservice_pb2
|
|
52
|
+
|
|
53
|
+
class MyServicer(myservice_grpc.MyServiceBase):
|
|
54
|
+
async def Greet(self, stream):
|
|
55
|
+
request = await stream.recv_message()
|
|
56
|
+
await stream.send_message(myservice_pb2.GreetResponse(message=f"hello {request.name}"))
|
|
57
|
+
|
|
58
|
+
class MyPlugin(Plugin):
|
|
59
|
+
def servicers(self, broker):
|
|
60
|
+
return [MyServicer()]
|
|
61
|
+
def stub(self, broker, channel: Channel):
|
|
62
|
+
return myservice_grpc.MyServiceStub(channel)
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
serve(ServeConfig(
|
|
66
|
+
handshake_config=HandshakeConfig(
|
|
67
|
+
protocol_version=1,
|
|
68
|
+
magic_cookie_key="MYPLUGIN_COOKIE",
|
|
69
|
+
magic_cookie_value="hello",
|
|
70
|
+
),
|
|
71
|
+
plugins={"my": MyPlugin()},
|
|
72
|
+
))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Host (async)
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import asyncio, sys
|
|
79
|
+
from pyplugin import Client, ClientConfig, HandshakeConfig
|
|
80
|
+
|
|
81
|
+
async def main():
|
|
82
|
+
async with Client(ClientConfig(
|
|
83
|
+
handshake_config=HandshakeConfig(1, "MYPLUGIN_COOKIE", "hello"),
|
|
84
|
+
plugins={"my": MyPlugin()},
|
|
85
|
+
cmd=[sys.executable, "my_plugin.py"],
|
|
86
|
+
auto_mtls=True, # P-521 mTLS, fully wire-compatible with go-plugin
|
|
87
|
+
)) as client:
|
|
88
|
+
stub = client.dispense("my")
|
|
89
|
+
resp = await stub.Greet(myservice_pb2.GreetRequest(name="world"))
|
|
90
|
+
print(resp.message)
|
|
91
|
+
|
|
92
|
+
asyncio.run(main())
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## What's implemented
|
|
96
|
+
|
|
97
|
+
| Feature | Status |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| stdout handshake protocol (6/7 segments, base64.RawStdEncoding cert) | ✅ |
|
|
100
|
+
| magic cookie validation | ✅ |
|
|
101
|
+
| gRPC transport: unix sockets (POSIX) and TCP loopback | ✅ |
|
|
102
|
+
| AutoMTLS with **ECDSA P-521 / SHA-512** (matches go-plugin) | ✅ |
|
|
103
|
+
| `GRPCController.Shutdown` graceful exit | ✅ |
|
|
104
|
+
| Kill ladder: Shutdown → SIGTERM → SIGKILL | ✅ |
|
|
105
|
+
| stderr forwarding with hclog parser (JSON + pretty) | ✅ |
|
|
106
|
+
| `GRPCBroker` bidirectional sub-channels (Accept/Dial) | ✅ |
|
|
107
|
+
| `GRPCStdio` post-handshake stdout/stderr stream | ✅ |
|
|
108
|
+
| `ReattachConfig` (host re-connects to running plugin) | ✅ |
|
|
109
|
+
| `VersionedPlugins` negotiation | ✅ |
|
|
110
|
+
| gRPC reflection + health (service name `plugin`) | ✅ |
|
|
111
|
+
| `PLUGIN_MULTIPLEX_GRPC` (broker over single socket) | ❌ deferred (advertised as not supported) |
|
|
112
|
+
|
|
113
|
+
## Verified Python ↔ Go interop
|
|
114
|
+
|
|
115
|
+
The test suite includes 4 real interop tests against upstream go-plugin:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
tests/interop/test_python_host_drives_go_plugin.py
|
|
119
|
+
test_python_host_drives_go_plugin_no_mtls ✓
|
|
120
|
+
test_python_host_drives_go_plugin_with_p521_automtls ✓
|
|
121
|
+
|
|
122
|
+
tests/interop/test_go_host_drives_python_plugin.py
|
|
123
|
+
test_go_host_drives_python_plugin_no_mtls ✓
|
|
124
|
+
test_go_host_drives_python_plugin_with_p521_automtls ✓
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
These run only when the binaries are present; the README of
|
|
128
|
+
`tests/interop/` describes how to build them. Out of the box you can
|
|
129
|
+
reproduce the matrix locally:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Build go-plugin's example KV plugin (Go)
|
|
133
|
+
git clone --depth=1 https://github.com/hashicorp/go-plugin /tmp/gp
|
|
134
|
+
(cd /tmp/gp/examples/grpc && go build -o /tmp/plugin-go-grpc ./plugin-go-grpc)
|
|
135
|
+
PYPLUGIN_GO_PLUGIN_KV=/tmp/plugin-go-grpc pytest tests/interop/test_python_host_drives_go_plugin.py
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
For the Go-host-drives-Python-plugin direction, see the small Go host
|
|
139
|
+
template at the end of this README — drop it into `tests/interop/go-host/`
|
|
140
|
+
with a `replace` directive in `go.mod` pointing at the local go-plugin
|
|
141
|
+
clone, `go build`, then point `PYPLUGIN_GO_HOST_BIN` at the binary.
|
|
142
|
+
|
|
143
|
+
## Layout
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
src/pyplugin/
|
|
147
|
+
handshake.py # stdout protocol line format/parse
|
|
148
|
+
cookie.py # magic-cookie validation
|
|
149
|
+
mtls.py # ephemeral P-521 cert generation + ssl.SSLContext builders
|
|
150
|
+
transport.py # unix / tcp listener helpers
|
|
151
|
+
server.py # serve(ServeConfig) — sync entry, internal asyncio loop
|
|
152
|
+
client.py # Client / ClientConfig — async host launcher
|
|
153
|
+
process.py # cross-platform subprocess termination
|
|
154
|
+
reattach.py # ReattachConfig
|
|
155
|
+
controller.py # GRPCController.Shutdown servicer (grpclib async)
|
|
156
|
+
broker.py # GRPCBroker bidirectional multiplexer (grpclib async)
|
|
157
|
+
stdio.py # GRPCStdio post-handshake stream (grpclib async)
|
|
158
|
+
health.py # static grpc.health.v1 servicer (returns SERVING for "plugin")
|
|
159
|
+
plugin.py # Plugin ABC, PluginSet, VersionedPlugins
|
|
160
|
+
logging_bridge.py # hclog (JSON + pretty) line parser
|
|
161
|
+
errors.py # exception hierarchy
|
|
162
|
+
proto/ # vendored .proto files from go-plugin (verbatim)
|
|
163
|
+
_generated/ # checked-in grpclib stubs
|
|
164
|
+
fixtures/example_kv/ # example KV plugin used by smoke tests
|
|
165
|
+
tests/ # 40 unit + Python↔Python tests
|
|
166
|
+
tests/interop/ # 4 real-go-plugin interop tests
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
python3 -m venv .venv
|
|
173
|
+
.venv/bin/pip install -e '.[dev]'
|
|
174
|
+
.venv/bin/python scripts/gen_protos.py # regenerate stubs
|
|
175
|
+
.venv/bin/python -m pytest # run tests
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Go host template
|
|
179
|
+
|
|
180
|
+
Use this with `go.mod`'s `replace github.com/hashicorp/go-plugin => /path/to/clone`:
|
|
181
|
+
|
|
182
|
+
```go
|
|
183
|
+
package main
|
|
184
|
+
|
|
185
|
+
import (
|
|
186
|
+
"fmt"; "io"; "log"; "os"; "os/exec"
|
|
187
|
+
hclog "github.com/hashicorp/go-hclog"
|
|
188
|
+
"github.com/hashicorp/go-plugin"
|
|
189
|
+
"github.com/hashicorp/go-plugin/examples/grpc/shared"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
func main() {
|
|
193
|
+
log.SetOutput(io.Discard)
|
|
194
|
+
client := plugin.NewClient(&plugin.ClientConfig{
|
|
195
|
+
HandshakeConfig: shared.Handshake,
|
|
196
|
+
Plugins: map[string]plugin.Plugin{shared.PluginGRPC: &shared.KVGRPCPlugin{}},
|
|
197
|
+
Cmd: exec.Command(os.Args[1], os.Args[2]),
|
|
198
|
+
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
|
|
199
|
+
AutoMTLS: os.Getenv("AUTO_MTLS") == "1",
|
|
200
|
+
Logger: hclog.New(&hclog.LoggerOptions{Output: io.Discard, Level: hclog.Off}),
|
|
201
|
+
})
|
|
202
|
+
defer client.Kill()
|
|
203
|
+
rpc, err := client.Client(); if err != nil { panic(err) }
|
|
204
|
+
raw, err := rpc.Dispense(shared.PluginGRPC); if err != nil { panic(err) }
|
|
205
|
+
kv := raw.(shared.KV)
|
|
206
|
+
if err := kv.Put(os.Args[4], []byte(os.Args[5])); err != nil { panic(err) }
|
|
207
|
+
v, err := kv.Get(os.Args[4]); if err != nil { panic(err) }
|
|
208
|
+
fmt.Print(string(v))
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT. The vendored `.proto` files in `src/pyplugin/proto/` retain their
|
|
215
|
+
upstream MPL-2.0 headers from
|
|
216
|
+
[hashicorp/go-plugin](https://github.com/hashicorp/go-plugin); MPL-2.0 is
|
|
217
|
+
file-level and is compatible with MIT for the rest of the project.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Greeter — minimal pyplugin example
|
|
2
|
+
|
|
3
|
+
A standalone, runnable example showing the smallest end-to-end shape of a
|
|
4
|
+
pyplugin host + plugin.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
greeter/
|
|
8
|
+
├── proto/greeter.proto # service definition (Greet, Count)
|
|
9
|
+
├── generated/ # checked-in grpclib stubs
|
|
10
|
+
├── shared.py # HandshakeConfig + Plugin glue (used by both sides)
|
|
11
|
+
├── plugin.py # plugin process entry point
|
|
12
|
+
├── host.py # host that launches plugin.py and calls it
|
|
13
|
+
└── README.md # this file
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Run it
|
|
17
|
+
|
|
18
|
+
From the repo root:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# install pyplugin in dev mode if you haven't
|
|
22
|
+
.venv/bin/pip install -e '.[dev]'
|
|
23
|
+
|
|
24
|
+
# launch the host — it spawns the plugin subprocess and makes two RPCs
|
|
25
|
+
.venv/bin/python examples/greeter/host.py "ada"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Expected output:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
launching plugin (auto_mtls=False)...
|
|
32
|
+
Greet: 'hello, ada!'
|
|
33
|
+
Count('the quick brown fox jumps over the lazy dog'): letters=35, words=9
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## With AutoMTLS (ECDSA P-521)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
AUTO_MTLS=1 .venv/bin/python examples/greeter/host.py "ada"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The host generates an ephemeral P-521 cert, hands its public key to the plugin
|
|
43
|
+
via `PLUGIN_CLIENT_CERT`, the plugin returns its own P-521 cert in handshake
|
|
44
|
+
field 6 (raw-DER, base64-RawStdEncoding'd), and the gRPC channel runs over
|
|
45
|
+
mTLS — exactly matching go-plugin's wire format.
|
|
46
|
+
|
|
47
|
+
## Regenerating the stubs
|
|
48
|
+
|
|
49
|
+
If you change `proto/greeter.proto`, regenerate:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
.venv/bin/python -m grpc_tools.protoc -Iexamples/greeter/proto \
|
|
53
|
+
--python_out=examples/greeter/generated \
|
|
54
|
+
--grpclib_python_out=examples/greeter/generated \
|
|
55
|
+
--plugin=protoc-gen-grpclib_python=$(pwd)/.venv/bin/protoc-gen-grpclib_python \
|
|
56
|
+
examples/greeter/proto/greeter.proto
|
|
57
|
+
|
|
58
|
+
# Fix the generated absolute import to a relative one
|
|
59
|
+
sed -i.bak 's/^import \(greeter_pb2\)/from . import \1/' \
|
|
60
|
+
examples/greeter/generated/greeter_grpc.py
|
|
61
|
+
rm examples/greeter/generated/*.bak
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## What this example shows
|
|
65
|
+
|
|
66
|
+
1. **The Plugin glue pattern** — `GreeterPlugin` lives in `shared.py` and is
|
|
67
|
+
imported by both `host.py` and `plugin.py`. Its `servicers()` method
|
|
68
|
+
returns the grpclib servicer instances on the plugin side; its `stub()`
|
|
69
|
+
method builds a typed client on the host side. This mirrors go-plugin's
|
|
70
|
+
`GRPCPlugin` interface.
|
|
71
|
+
2. **HandshakeConfig** — both sides agree on the magic cookie key + value
|
|
72
|
+
and a protocol version. If the user accidentally runs `plugin.py`
|
|
73
|
+
directly, they get the friendly "this is a plugin, not a CLI" message
|
|
74
|
+
and exit code 1.
|
|
75
|
+
3. **Async API** — servicers are `async def`, the host uses
|
|
76
|
+
`async with Client(...)` and awaits stub methods. This is required
|
|
77
|
+
because pyplugin runs on grpclib (pure-Python on top of `ssl`/OpenSSL,
|
|
78
|
+
which supports the P-521 cert format that go-plugin uses).
|
|
79
|
+
4. **AutoMTLS toggle** — flipping `auto_mtls=True` is the only difference
|
|
80
|
+
between insecure and full ECDSA-P-521 mTLS. Wire-compatible with a Go
|
|
81
|
+
host that sets `AutoMTLS: true`.
|
|
File without changes
|
|
File without changes
|