kgrok 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.
- kgrok-0.1.0/LICENSE.txt +21 -0
- kgrok-0.1.0/MANIFEST.in +1 -0
- kgrok-0.1.0/PKG-INFO +74 -0
- kgrok-0.1.0/README.md +29 -0
- kgrok-0.1.0/pyproject.toml +69 -0
- kgrok-0.1.0/requirements.in +3 -0
- kgrok-0.1.0/setup.cfg +4 -0
- kgrok-0.1.0/setup.py +8 -0
- kgrok-0.1.0/src/kgrok/__init__.py +0 -0
- kgrok-0.1.0/src/kgrok/local.py +268 -0
- kgrok-0.1.0/src/kgrok/messages.py +85 -0
- kgrok-0.1.0/src/kgrok/remote.py +169 -0
- kgrok-0.1.0/src/kgrok.egg-info/PKG-INFO +74 -0
- kgrok-0.1.0/src/kgrok.egg-info/SOURCES.txt +16 -0
- kgrok-0.1.0/src/kgrok.egg-info/dependency_links.txt +1 -0
- kgrok-0.1.0/src/kgrok.egg-info/entry_points.txt +3 -0
- kgrok-0.1.0/src/kgrok.egg-info/requires.txt +3 -0
- kgrok-0.1.0/src/kgrok.egg-info/top_level.txt +1 -0
kgrok-0.1.0/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Daniel Golding
|
|
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.
|
kgrok-0.1.0/MANIFEST.in
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include requirements.in
|
kgrok-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: kgrok
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: forward local processes into k8s cluster
|
|
5
|
+
Author: Daniel Golding
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2024 Daniel Golding
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/cakemanny/kgrok
|
|
29
|
+
Project-URL: Repository, https://github.com/cakemanny/kgrok.git
|
|
30
|
+
Keywords: kubernetes,proxy
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Environment :: Console
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Requires-Python: >=3.12
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
License-File: LICENSE.txt
|
|
42
|
+
Requires-Dist: click
|
|
43
|
+
Requires-Dist: lightkube
|
|
44
|
+
Requires-Dist: trio
|
|
45
|
+
|
|
46
|
+
# Kgrok
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
kgrok my-svc localhost:8080
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
We assume you have a service already listening locally on port 8080
|
|
55
|
+
and you have your `kubectl` context configured for the cluster you want to
|
|
56
|
+
listen receive traffic from.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
Using [pipx] might be the most reliable.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
pipx install kgrok
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
[pipx]: https://pipx.pypa.io/stable/
|
|
68
|
+
|
|
69
|
+
If you are however already in a Python 3.12 virtual environment, or inside
|
|
70
|
+
a container with pip, you can probably get away with it.
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
python3 -m pip install kgrok
|
|
74
|
+
```
|
kgrok-0.1.0/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Kgrok
|
|
2
|
+
|
|
3
|
+
## Quick Start
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
kgrok my-svc localhost:8080
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
We assume you have a service already listening locally on port 8080
|
|
10
|
+
and you have your `kubectl` context configured for the cluster you want to
|
|
11
|
+
listen receive traffic from.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Using [pipx] might be the most reliable.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
pipx install kgrok
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
[pipx]: https://pipx.pypa.io/stable/
|
|
23
|
+
|
|
24
|
+
If you are however already in a Python 3.12 virtual environment, or inside
|
|
25
|
+
a container with pip, you can probably get away with it.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
python3 -m pip install kgrok
|
|
29
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
dynamic = ["dependencies"]
|
|
7
|
+
|
|
8
|
+
name = "kgrok"
|
|
9
|
+
description = "forward local processes into k8s cluster"
|
|
10
|
+
|
|
11
|
+
# TODO: use dynamic version
|
|
12
|
+
# dynamic = ["version"]
|
|
13
|
+
# https://packaging.python.org/en/latest/guides/single-sourcing-package-version/#single-sourcing-the-version
|
|
14
|
+
version = "0.1.0"
|
|
15
|
+
|
|
16
|
+
requires-python = ">=3.12"
|
|
17
|
+
authors = [
|
|
18
|
+
{name = "Daniel Golding"}
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
readme = {file = "README.md", content-type = "text/markdown" }
|
|
22
|
+
keywords = ["kubernetes", "proxy"]
|
|
23
|
+
license = {file = "LICENSE.txt"}
|
|
24
|
+
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 3 - Alpha",
|
|
27
|
+
"Environment :: Console",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Topic :: Internet :: Proxy Servers",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/cakemanny/kgrok"
|
|
38
|
+
Repository = "https://github.com/cakemanny/kgrok.git"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
kgrok = "kgrok.local:main"
|
|
42
|
+
kgrok-remote = "kgrok.remote:main"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
minversion = "6.0"
|
|
47
|
+
testpaths = [
|
|
48
|
+
"tests",
|
|
49
|
+
]
|
|
50
|
+
trio_mode = true
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
src = ["src", "test"]
|
|
54
|
+
target-version = "py312"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
|
58
|
+
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
|
|
59
|
+
# McCabe complexity (`C901`) by default.
|
|
60
|
+
select = [
|
|
61
|
+
"E4", "E7", "E9", "F", # defaults
|
|
62
|
+
"ASYNC",
|
|
63
|
+
"TRIO",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.pyright]
|
|
67
|
+
|
|
68
|
+
# due to way lightkube exports stuff
|
|
69
|
+
reportPrivateImportUsage = false
|
kgrok-0.1.0/setup.cfg
ADDED
kgrok-0.1.0/setup.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from contextlib import AsyncExitStack
|
|
3
|
+
import functools
|
|
4
|
+
import logging
|
|
5
|
+
import subprocess
|
|
6
|
+
import typing
|
|
7
|
+
|
|
8
|
+
from lightkube import AsyncClient
|
|
9
|
+
from lightkube.config.kubeconfig import KubeConfig, SingleConfig
|
|
10
|
+
from lightkube.models.core_v1 import ServicePort, ServiceSpec
|
|
11
|
+
from lightkube.models.meta_v1 import ObjectMeta
|
|
12
|
+
from lightkube.resources.core_v1 import Service
|
|
13
|
+
from trio.abc import SendStream, SendChannel, ReceiveChannel
|
|
14
|
+
import lightkube
|
|
15
|
+
import click
|
|
16
|
+
import trio
|
|
17
|
+
|
|
18
|
+
from kgrok.messages import (
|
|
19
|
+
ConnectionClosed, DataReceived, NewConnection, Text as encoding,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def handle_connection(
|
|
27
|
+
conn_id: int,
|
|
28
|
+
local_svc_addr: tuple[str, int],
|
|
29
|
+
response_channel: SendChannel,
|
|
30
|
+
recv: ReceiveChannel,
|
|
31
|
+
):
|
|
32
|
+
host, port = local_svc_addr
|
|
33
|
+
local_stream = await trio.open_tcp_stream(host, port)
|
|
34
|
+
|
|
35
|
+
async def remote_to_local(recv):
|
|
36
|
+
async with recv: # not sure if it's our job to close this or not...
|
|
37
|
+
async for message in recv:
|
|
38
|
+
match message:
|
|
39
|
+
case DataReceived(_, data):
|
|
40
|
+
await local_stream.send_all(data)
|
|
41
|
+
case ConnectionClosed():
|
|
42
|
+
await local_stream.send_eof()
|
|
43
|
+
case _:
|
|
44
|
+
raise TypeError(type(message))
|
|
45
|
+
|
|
46
|
+
async def local_to_remote(response_channel):
|
|
47
|
+
async for data in local_stream:
|
|
48
|
+
await response_channel.send(DataReceived(conn_id, data))
|
|
49
|
+
await response_channel.send(ConnectionClosed(conn_id))
|
|
50
|
+
|
|
51
|
+
async with local_stream, trio.open_nursery() as nursery:
|
|
52
|
+
nursery.start_soon(remote_to_local, recv)
|
|
53
|
+
nursery.start_soon(local_to_remote, response_channel)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def write_responses(remote_stdin: SendStream, *,
|
|
57
|
+
task_status=trio.TASK_STATUS_IGNORED):
|
|
58
|
+
send, recv = trio.open_memory_channel(0)
|
|
59
|
+
task_status.started(send)
|
|
60
|
+
|
|
61
|
+
async with recv:
|
|
62
|
+
async for message in recv:
|
|
63
|
+
await remote_stdin.send_all(encoding.encode(message))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def accept_connections(
|
|
67
|
+
nursery: trio.Nursery,
|
|
68
|
+
remote_stdio: trio.StapledStream,
|
|
69
|
+
local_svc_addr: tuple[str, int]
|
|
70
|
+
):
|
|
71
|
+
|
|
72
|
+
response_channel: SendChannel = await nursery.start(
|
|
73
|
+
write_responses, remote_stdio.send_stream,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
connections: dict[int, SendChannel] = {}
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
while True:
|
|
80
|
+
message = await encoding.read_ipc_message(remote_stdio)
|
|
81
|
+
match message:
|
|
82
|
+
case NewConnection(conn_id):
|
|
83
|
+
send, recv = trio.open_memory_channel(80)
|
|
84
|
+
connections[conn_id] = send
|
|
85
|
+
nursery.start_soon(
|
|
86
|
+
handle_connection,
|
|
87
|
+
conn_id, local_svc_addr, response_channel, recv,
|
|
88
|
+
)
|
|
89
|
+
case DataReceived(conn_id, _):
|
|
90
|
+
conn = connections.get(conn_id)
|
|
91
|
+
if conn:
|
|
92
|
+
await conn.send(message)
|
|
93
|
+
else:
|
|
94
|
+
log.warning(f'dropped data for {conn_id=}')
|
|
95
|
+
case ConnectionClosed(conn_id):
|
|
96
|
+
conn = connections.get(conn_id)
|
|
97
|
+
if conn:
|
|
98
|
+
await conn.send(message)
|
|
99
|
+
await conn.aclose()
|
|
100
|
+
del connections[conn_id]
|
|
101
|
+
else:
|
|
102
|
+
log.warning(f'unknown connection {conn_id=}')
|
|
103
|
+
case None:
|
|
104
|
+
break
|
|
105
|
+
finally:
|
|
106
|
+
async with AsyncExitStack() as stack:
|
|
107
|
+
for chan in connections.values():
|
|
108
|
+
stack.push_async_exit(chan)
|
|
109
|
+
|
|
110
|
+
###
|
|
111
|
+
### Startup
|
|
112
|
+
###
|
|
113
|
+
|
|
114
|
+
def run_remote(port):
|
|
115
|
+
|
|
116
|
+
return functools.partial(
|
|
117
|
+
trio.run_process,
|
|
118
|
+
[".venv/bin/kgrok-remote", "--port", str(port)],
|
|
119
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
120
|
+
env={'PYTHONUNBUFFERED': "1"}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def prepare_labels(labels: dict[str, str]) -> str:
|
|
125
|
+
return ','.join(
|
|
126
|
+
'='.join((k, v)) for k,v in labels.items()
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_kubectl_run(port, labels, namespace):
|
|
131
|
+
|
|
132
|
+
default_image = "ghcr.io/cakemanny/kgrok-remote"
|
|
133
|
+
image = os.getenv("KGROK_REMOTE_IMAGE", default_image)
|
|
134
|
+
|
|
135
|
+
extra_args = []
|
|
136
|
+
# For now this is something of a good indicator for a locally loaded
|
|
137
|
+
# dev image
|
|
138
|
+
if '/' not in image: # locally loaded dev image
|
|
139
|
+
extra_args.append("--image-pull-policy=Never")
|
|
140
|
+
|
|
141
|
+
if namespace and namespace != 'default':
|
|
142
|
+
extra_args.append(f"--namespace={namespace}")
|
|
143
|
+
|
|
144
|
+
return functools.partial(
|
|
145
|
+
trio.run_process,
|
|
146
|
+
[
|
|
147
|
+
# The --rm doesn't seem to work without --tty
|
|
148
|
+
# so, we have to call kubectl delete
|
|
149
|
+
"kubectl", "run", "-i", "--rm", "kgrok-remote",
|
|
150
|
+
f"--image={image}",
|
|
151
|
+
"--port", str(port),
|
|
152
|
+
"--restart=Never",
|
|
153
|
+
"--labels=" + prepare_labels(labels), # shell vuln?
|
|
154
|
+
*extra_args,
|
|
155
|
+
"--",
|
|
156
|
+
"--port", str(port),
|
|
157
|
+
],
|
|
158
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
159
|
+
# capture stderr?
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
type TargetSpec = tuple[dict[str, str], int]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def target_spec_from_service(svc: Service) -> TargetSpec:
|
|
167
|
+
assert svc.metadata is not None, f'svc.metadata is None: {svc=}'
|
|
168
|
+
assert svc.spec is not None, f'svc.spec is None: {svc=}'
|
|
169
|
+
name = svc.metadata.name
|
|
170
|
+
|
|
171
|
+
# I checked the api spec for this one
|
|
172
|
+
selector = typing.cast(dict[str,str], svc.spec.selector)
|
|
173
|
+
|
|
174
|
+
ports = svc.spec.ports or []
|
|
175
|
+
if len(ports) <= 1:
|
|
176
|
+
raise ValueError(f'service {name} has no ports')
|
|
177
|
+
if len(ports) > 1:
|
|
178
|
+
log.warning(f'service {name} has more than one port, using first')
|
|
179
|
+
|
|
180
|
+
# Future: we could exclude udp ports
|
|
181
|
+
# Future: we could prefer http appProtocol over other options...
|
|
182
|
+
|
|
183
|
+
port = ports[0].targetPort or ports[0].port
|
|
184
|
+
if isinstance(port, str):
|
|
185
|
+
# Future: look up selected pods and work out what the name
|
|
186
|
+
# refers to
|
|
187
|
+
raise ValueError(f'service {name} has a named port, named ports not supported')
|
|
188
|
+
|
|
189
|
+
return selector, port
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def setup_service(
|
|
193
|
+
name: str, namespace: str, port: int, exit_stack: AsyncExitStack,
|
|
194
|
+
) -> TargetSpec:
|
|
195
|
+
|
|
196
|
+
config: SingleConfig | None = KubeConfig.from_env().get()
|
|
197
|
+
assert config is not None
|
|
198
|
+
client = AsyncClient(config=config)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
svc = await client.get(Service, name, namespace=namespace)
|
|
202
|
+
assert isinstance(svc, Service)
|
|
203
|
+
return target_spec_from_service(svc)
|
|
204
|
+
except lightkube.ApiError as e:
|
|
205
|
+
if e.status.reason == "NotFound":
|
|
206
|
+
labels = {'app': name}
|
|
207
|
+
service = Service(
|
|
208
|
+
metadata=ObjectMeta(
|
|
209
|
+
name=name, namespace=namespace, labels=labels,
|
|
210
|
+
),
|
|
211
|
+
spec=ServiceSpec(
|
|
212
|
+
ports=[ServicePort(port=port)], selector=labels,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
await client.create(service, name)
|
|
216
|
+
exit_stack.push_async_callback(
|
|
217
|
+
client.delete, Service, name, namespace=namespace,
|
|
218
|
+
)
|
|
219
|
+
return labels, port
|
|
220
|
+
else:
|
|
221
|
+
raise
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def async_main(service_name: str, host: str, port: int, namespace: str):
|
|
225
|
+
|
|
226
|
+
async with AsyncExitStack() as exit_stack:
|
|
227
|
+
labels, remote_port = await setup_service(
|
|
228
|
+
service_name, namespace, port, exit_stack)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
async with trio.open_nursery() as nursery:
|
|
232
|
+
process = await nursery.start(
|
|
233
|
+
run_kubectl_run(remote_port, labels, namespace)
|
|
234
|
+
)
|
|
235
|
+
nursery.start_soon(
|
|
236
|
+
accept_connections, nursery, process.stdio, (host, port),
|
|
237
|
+
)
|
|
238
|
+
except subprocess.CalledProcessError as cpe:
|
|
239
|
+
log.warning(f'calledprocesserror {cpe=}')
|
|
240
|
+
raise
|
|
241
|
+
finally:
|
|
242
|
+
try:
|
|
243
|
+
await trio.run_process(["kubectl", "delete", "pod", "kgrok-remote"])
|
|
244
|
+
except Exception as e:
|
|
245
|
+
log.warning("failed to delete pod", repr(e))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@click.command()
|
|
249
|
+
@click.argument('service-name')
|
|
250
|
+
@click.argument('host-port')
|
|
251
|
+
@click.option('-n', '--namespace', default="default" , help='the kubernetes namespace')
|
|
252
|
+
def main(service_name, host_port, namespace):
|
|
253
|
+
host = 'localhost'
|
|
254
|
+
if ':' in host_port:
|
|
255
|
+
host, port = host_port.split(':')
|
|
256
|
+
port = int(port)
|
|
257
|
+
else:
|
|
258
|
+
port = int(host_port)
|
|
259
|
+
|
|
260
|
+
logging.basicConfig(
|
|
261
|
+
level=logging.INFO,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
trio.run(async_main, service_name, host, port, namespace)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == '__main__':
|
|
268
|
+
main()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# TODO: rename module IPC?
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import io
|
|
4
|
+
|
|
5
|
+
import trio.abc
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# A text version of our messages for debugability during development
|
|
9
|
+
class Text:
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def new_connection(conn_id) -> bytes:
|
|
13
|
+
return f'NC:{conn_id:08x}\n'.encode()
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def connection_closed(conn_id) -> bytes:
|
|
17
|
+
return f'CC:{conn_id:08x}\n'.encode()
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def data_received(conn_id, data: bytes) -> bytes:
|
|
21
|
+
b = io.BytesIO()
|
|
22
|
+
b.write(f'DR:{conn_id:08x}:{len(data):08x}:'.encode())
|
|
23
|
+
b.write(data)
|
|
24
|
+
b.write(b'\n')
|
|
25
|
+
return b.getvalue()
|
|
26
|
+
|
|
27
|
+
_cc_message_length = len(connection_closed(0))
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
async def read_ipc_message(stream: trio.abc.ReceiveStream):
|
|
31
|
+
def extract_conn_id(prefix):
|
|
32
|
+
return int(prefix[3:11], 16)
|
|
33
|
+
|
|
34
|
+
prefix = await stream.receive_some(Text._cc_message_length)
|
|
35
|
+
if prefix == b'':
|
|
36
|
+
return None
|
|
37
|
+
if prefix.startswith(b'NC:'):
|
|
38
|
+
conn_id = extract_conn_id(prefix)
|
|
39
|
+
return NewConnection(conn_id)
|
|
40
|
+
if prefix.startswith(b'CC:'):
|
|
41
|
+
conn_id = extract_conn_id(prefix)
|
|
42
|
+
return ConnectionClosed(conn_id)
|
|
43
|
+
if prefix.startswith(b'DR:'):
|
|
44
|
+
conn_id = extract_conn_id(prefix)
|
|
45
|
+
length = await stream.receive_some(9)
|
|
46
|
+
assert length[-1:] == b':', f"{length=}"
|
|
47
|
+
length = int(length[:8], 16)
|
|
48
|
+
data = await stream.receive_some(length + 1)
|
|
49
|
+
assert data[-1:] == b'\n', f'{data=}'
|
|
50
|
+
data = data[:-1]
|
|
51
|
+
return DataReceived(conn_id, data)
|
|
52
|
+
raise Exception(f'Unknown message with {prefix=}')
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def can_encode(msg) -> bool:
|
|
56
|
+
return isinstance(msg, (DataReceived, ConnectionClosed, NewConnection))
|
|
57
|
+
|
|
58
|
+
type Message = DataReceived | ConnectionClosed | NewConnection
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def encode(cls, msg: Message) -> bytes:
|
|
62
|
+
match msg:
|
|
63
|
+
case DataReceived(conn_id, data):
|
|
64
|
+
return cls.data_received(conn_id, data)
|
|
65
|
+
case ConnectionClosed(conn_id):
|
|
66
|
+
return cls.connection_closed(conn_id)
|
|
67
|
+
case NewConnection(conn_id):
|
|
68
|
+
return cls.new_connection(conn_id)
|
|
69
|
+
raise TypeError(f'cannot encode {msg.__class.__.__name__}')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class DataReceived:
|
|
74
|
+
conn_id: int
|
|
75
|
+
data: bytes
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ConnectionClosed:
|
|
80
|
+
conn_id: int
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class NewConnection:
|
|
85
|
+
conn_id: int
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from itertools import count
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from trio.abc import SendChannel, ReceiveChannel
|
|
8
|
+
import click
|
|
9
|
+
import trio
|
|
10
|
+
|
|
11
|
+
from kgrok.messages import (
|
|
12
|
+
ConnectionClosed, DataReceived, Text as encoding,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
CONNECTION_COUNTER = count()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Handler:
|
|
21
|
+
def __init__(self, new_connections: SendChannel[tuple[int, SendChannel]]) -> None:
|
|
22
|
+
self.stdout = trio.wrap_file(sys.stdout.buffer)
|
|
23
|
+
self.new_connections = new_connections
|
|
24
|
+
|
|
25
|
+
async def __call__(self, stream: trio.SocketStream):
|
|
26
|
+
conn_id = next(CONNECTION_COUNTER)
|
|
27
|
+
|
|
28
|
+
async with trio.open_nursery() as nursery:
|
|
29
|
+
nursery.start_soon(self._handle_recv, conn_id, stream)
|
|
30
|
+
nursery.start_soon(self._handle_resp, conn_id, stream)
|
|
31
|
+
|
|
32
|
+
async def _handle_resp(self, conn_id: int, stream: trio.SocketStream):
|
|
33
|
+
# The thought with the 80 here is that, configuring it to be 0
|
|
34
|
+
# would mean that all connections would block if this client stops
|
|
35
|
+
# reading ...
|
|
36
|
+
# The correct thing to do would be to
|
|
37
|
+
# - a: timeout the sends and
|
|
38
|
+
# - b: stop reading in recv if sends are not working.
|
|
39
|
+
send, recv = trio.open_memory_channel(80)
|
|
40
|
+
await self.new_connections.send((conn_id, send,))
|
|
41
|
+
|
|
42
|
+
async with recv:
|
|
43
|
+
async for value in recv:
|
|
44
|
+
match value:
|
|
45
|
+
case DataReceived(conn_id, data):
|
|
46
|
+
await stream.send_all(data)
|
|
47
|
+
case ConnectionClosed(conn_id):
|
|
48
|
+
await stream.send_eof()
|
|
49
|
+
case _:
|
|
50
|
+
raise TypeError(type(value))
|
|
51
|
+
|
|
52
|
+
async def _handle_recv(self, conn_id: int, stream: trio.SocketStream):
|
|
53
|
+
# we must've got a connection
|
|
54
|
+
await self.stdout.write(encoding.new_connection(conn_id))
|
|
55
|
+
try:
|
|
56
|
+
async for data in stream:
|
|
57
|
+
await self.stdout.write(encoding.data_received(conn_id, data))
|
|
58
|
+
finally:
|
|
59
|
+
await self.stdout.write(encoding.connection_closed(conn_id))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def combine[L, R](
|
|
63
|
+
left: ReceiveChannel[L],
|
|
64
|
+
right: ReceiveChannel[R],
|
|
65
|
+
*,
|
|
66
|
+
task_status=trio.TASK_STATUS_IGNORED,
|
|
67
|
+
):
|
|
68
|
+
out: SendChannel[tuple[L, None] | tuple[None, R]]
|
|
69
|
+
out, recv = trio.open_memory_channel(0)
|
|
70
|
+
task_status.started(recv)
|
|
71
|
+
|
|
72
|
+
async def read_left(cancel_scope):
|
|
73
|
+
async for value in left:
|
|
74
|
+
await out.send((value, None))
|
|
75
|
+
log.debug('cancel left')
|
|
76
|
+
cancel_scope.cancel()
|
|
77
|
+
|
|
78
|
+
async def read_right(cancel_scope):
|
|
79
|
+
async for value in right:
|
|
80
|
+
await out.send((None, value))
|
|
81
|
+
log.debug('cancel right')
|
|
82
|
+
cancel_scope.cancel()
|
|
83
|
+
|
|
84
|
+
async with out, trio.open_nursery() as nursery:
|
|
85
|
+
nursery.start_soon(read_left, nursery.cancel_scope)
|
|
86
|
+
nursery.start_soon(read_right, nursery.cancel_scope)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def dispatch_stdin(
|
|
90
|
+
from_decode: ReceiveChannel,
|
|
91
|
+
new_channels: ReceiveChannel[tuple[int, SendChannel]]
|
|
92
|
+
):
|
|
93
|
+
channels: dict[int, SendChannel] = {}
|
|
94
|
+
|
|
95
|
+
async with trio.open_nursery() as nursery:
|
|
96
|
+
messages_or_new_connections = await nursery.start(
|
|
97
|
+
combine, from_decode, new_channels)
|
|
98
|
+
|
|
99
|
+
async for (message, new_channel) in messages_or_new_connections:
|
|
100
|
+
if new_channel is not None:
|
|
101
|
+
conn_id, send = new_channel
|
|
102
|
+
channels[conn_id] = send
|
|
103
|
+
else:
|
|
104
|
+
assert message is not None
|
|
105
|
+
channel = channels.get(message.conn_id)
|
|
106
|
+
if channel is not None:
|
|
107
|
+
# Idea: one option might be to close the connection
|
|
108
|
+
# if the client stops reading.
|
|
109
|
+
await channel.send(message)
|
|
110
|
+
if isinstance(message, ConnectionClosed):
|
|
111
|
+
await channel.aclose()
|
|
112
|
+
del channels[message.conn_id]
|
|
113
|
+
else:
|
|
114
|
+
# This really happened once in testing and the client
|
|
115
|
+
# hung... so maybe we have a problem after all.
|
|
116
|
+
# I think we have to switch back to the dict
|
|
117
|
+
log.warning(
|
|
118
|
+
f'dropped message for {message.conn_id=}',
|
|
119
|
+
)
|
|
120
|
+
await messages_or_new_connections.aclose()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def decode_stdin(decoded: SendChannel):
|
|
124
|
+
# WARN: Absolutely no other use of stdin is allowed
|
|
125
|
+
async with decoded, trio.lowlevel.FdStream(os.dup(sys.stdin.fileno())) as stdin:
|
|
126
|
+
while True:
|
|
127
|
+
message = await encoding.read_ipc_message(stdin)
|
|
128
|
+
if message is None:
|
|
129
|
+
break
|
|
130
|
+
await decoded.send(message)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def handle_sigterm(cancel_scope: trio.CancelScope):
|
|
134
|
+
with trio.open_signal_receiver(signal.SIGTERM) as signal_aiter:
|
|
135
|
+
async for signum in signal_aiter:
|
|
136
|
+
assert signum == signal.SIGTERM
|
|
137
|
+
log.info('SIGTERM. cancelling')
|
|
138
|
+
cancel_scope.cancel()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def listen(port: int):
|
|
142
|
+
async with trio.open_nursery() as nursery:
|
|
143
|
+
from_decode, to_dispatch = trio.open_memory_channel(0)
|
|
144
|
+
nursery.start_soon(decode_stdin, from_decode)
|
|
145
|
+
# nc = new channels/connections
|
|
146
|
+
nc_from_handler, nc_to_dispatch = trio.open_memory_channel(0)
|
|
147
|
+
nursery.start_soon(dispatch_stdin, to_dispatch, nc_to_dispatch)
|
|
148
|
+
|
|
149
|
+
handler = Handler(nc_from_handler)
|
|
150
|
+
nursery.start_soon(trio.serve_tcp, handler, port)
|
|
151
|
+
nursery.start_soon(handle_sigterm, nursery.cancel_scope)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@click.command()
|
|
155
|
+
@click.option('--port', required=True, type=int)
|
|
156
|
+
def main(port: int):
|
|
157
|
+
logging.basicConfig(
|
|
158
|
+
level=logging.INFO,
|
|
159
|
+
format='%(levelname)s:%(asctime)s:%(name)s:%(message)s'
|
|
160
|
+
)
|
|
161
|
+
log.info('listening on port %d', port)
|
|
162
|
+
try:
|
|
163
|
+
trio.run(listen, port)
|
|
164
|
+
finally:
|
|
165
|
+
log.info('exiting')
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == '__main__':
|
|
169
|
+
main()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: kgrok
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: forward local processes into k8s cluster
|
|
5
|
+
Author: Daniel Golding
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2024 Daniel Golding
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/cakemanny/kgrok
|
|
29
|
+
Project-URL: Repository, https://github.com/cakemanny/kgrok.git
|
|
30
|
+
Keywords: kubernetes,proxy
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Environment :: Console
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Requires-Python: >=3.12
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
License-File: LICENSE.txt
|
|
42
|
+
Requires-Dist: click
|
|
43
|
+
Requires-Dist: lightkube
|
|
44
|
+
Requires-Dist: trio
|
|
45
|
+
|
|
46
|
+
# Kgrok
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
kgrok my-svc localhost:8080
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
We assume you have a service already listening locally on port 8080
|
|
55
|
+
and you have your `kubectl` context configured for the cluster you want to
|
|
56
|
+
listen receive traffic from.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
Using [pipx] might be the most reliable.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
pipx install kgrok
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
[pipx]: https://pipx.pypa.io/stable/
|
|
68
|
+
|
|
69
|
+
If you are however already in a Python 3.12 virtual environment, or inside
|
|
70
|
+
a container with pip, you can probably get away with it.
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
python3 -m pip install kgrok
|
|
74
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
requirements.in
|
|
6
|
+
setup.py
|
|
7
|
+
src/kgrok/__init__.py
|
|
8
|
+
src/kgrok/local.py
|
|
9
|
+
src/kgrok/messages.py
|
|
10
|
+
src/kgrok/remote.py
|
|
11
|
+
src/kgrok.egg-info/PKG-INFO
|
|
12
|
+
src/kgrok.egg-info/SOURCES.txt
|
|
13
|
+
src/kgrok.egg-info/dependency_links.txt
|
|
14
|
+
src/kgrok.egg-info/entry_points.txt
|
|
15
|
+
src/kgrok.egg-info/requires.txt
|
|
16
|
+
src/kgrok.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kgrok
|