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.
@@ -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.
@@ -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
@@ -0,0 +1,3 @@
1
+ click
2
+ lightkube
3
+ trio
kgrok-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
kgrok-0.1.0/setup.py ADDED
@@ -0,0 +1,8 @@
1
+ from setuptools import setup
2
+
3
+ with open('./requirements.in') as f:
4
+ dependencies = f.read().splitlines()
5
+
6
+ setup(
7
+ install_requires=dependencies
8
+ )
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,3 @@
1
+ [console_scripts]
2
+ kgrok = kgrok.local:main
3
+ kgrok-remote = kgrok.remote:main
@@ -0,0 +1,3 @@
1
+ click
2
+ lightkube
3
+ trio
@@ -0,0 +1 @@
1
+ kgrok