harness-toolbox 0.2.2__py3-none-any.whl
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.
- harness_toolbox/AGENTS.md +40 -0
- harness_toolbox/__init__.py +19 -0
- harness_toolbox/_mysql_values.py +46 -0
- harness_toolbox/address.py +170 -0
- harness_toolbox/client.py +8 -0
- harness_toolbox/diagnostics.py +73 -0
- harness_toolbox/environment.py +31 -0
- harness_toolbox/errors.py +56 -0
- harness_toolbox/host.py +44 -0
- harness_toolbox/kube/__init__.py +11 -0
- harness_toolbox/kube/client.py +784 -0
- harness_toolbox/kube/model.py +82 -0
- harness_toolbox/kube/resources.py +138 -0
- harness_toolbox/kube/selector.py +22 -0
- harness_toolbox/mysql.py +430 -0
- harness_toolbox/opensearch.py +331 -0
- harness_toolbox/pod_log.py +213 -0
- harness_toolbox/process.py +116 -0
- harness_toolbox/transport.py +167 -0
- harness_toolbox-0.2.2.dist-info/METADATA +14 -0
- harness_toolbox-0.2.2.dist-info/RECORD +22 -0
- harness_toolbox-0.2.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Python 平台工具箱
|
|
2
|
+
|
|
3
|
+
## 项目定位与边界
|
|
4
|
+
|
|
5
|
+
独立分发包 `harness-toolbox` 提供异步基础设施客户端。测试环境操作与诊断采集共享机制;
|
|
6
|
+
环境台账、凭据来源、业务查询、动作授权和结果判定由消费方拥有;环境内的网络地址解析由 toolbox 拥有。
|
|
7
|
+
|
|
8
|
+
## 代码地图与核心模块
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
harness_toolbox/
|
|
12
|
+
├── client.py # common 生命周期类型的兼容导出
|
|
13
|
+
├── environment.py # KubernetesEnvironment → KubernetesDataSource
|
|
14
|
+
├── address.py # IP/DNS/环境内 Service 地址候选与解析失败
|
|
15
|
+
├── errors.py # ToolboxError 契约与协议子类;适配器负责转换原生异常
|
|
16
|
+
├── diagnostics.py # 客户端连接路径快照,与异常独立
|
|
17
|
+
├── transport.py # 连接解析、直连、port-forward、Pod Python 路径
|
|
18
|
+
├── process.py # 有界子进程 I/O、退出结果与取消清理
|
|
19
|
+
├── kube/ # Pod 增删、状态等待、Event、exec 与隧道
|
|
20
|
+
├── opensearch.py # HTTP 池、有界响应与 scroll
|
|
21
|
+
├── mysql.py # SQLAlchemy async 池与 Pod Python 查询
|
|
22
|
+
├── pod_log.py # 按物理实例/窗口共享采集,依赖 Kubernetes 客户端
|
|
23
|
+
└── tests/ # 协议 stub 和本机 HTTP/进程测试
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 关键约定
|
|
27
|
+
|
|
28
|
+
- 根调用方通过 `async with ClientManager()` 管理执行期,子调用方只接收 ClientProvider。
|
|
29
|
+
- DataSource 创建不做外部 I/O;initialize 完成依赖获取后才发布成功,dispose 必须幂等。
|
|
30
|
+
- Service 名称必须用对应 Environment 的 kubeconfig/context 解析,不回退本机短名 DNS;仅初始化阶段切换地址,业务请求不重放。
|
|
31
|
+
- 同 key 的配置、凭据和容量必须一致;失败资源清理完成才允许重试。禁止日志输出凭据和查询内容。
|
|
32
|
+
- 协议依赖仅通过 extras 引入;通用生命周期归 harness-common,只使用标准库。Skill 必须显式安装所用协议 extra。
|
|
33
|
+
- Pod 删除通过 UID precondition 保证实例身份;exec/log API 没有原子 UID 条件,只能前后核验。
|
|
34
|
+
- ClientManager 释放连接、临时采集文件和子进程,不隐式删除消费方创建的远端 Pod。
|
|
35
|
+
|
|
36
|
+
## References
|
|
37
|
+
|
|
38
|
+
- [使用指南](../README.md)
|
|
39
|
+
- [生命周期与访问边界](../docs/lifecycle.md)
|
|
40
|
+
- [跨语言工具箱](../../../../../docs/toolbox.md)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Infrastructure mechanisms without business or environment configuration."""
|
|
2
|
+
|
|
3
|
+
from harness_toolbox.client import (
|
|
4
|
+
Client as Client,
|
|
5
|
+
)
|
|
6
|
+
from harness_toolbox.client import (
|
|
7
|
+
ClientManager as ClientManager,
|
|
8
|
+
)
|
|
9
|
+
from harness_toolbox.client import (
|
|
10
|
+
ClientProvider as ClientProvider,
|
|
11
|
+
)
|
|
12
|
+
from harness_toolbox.client import (
|
|
13
|
+
DataSource as DataSource,
|
|
14
|
+
)
|
|
15
|
+
from harness_toolbox.client import (
|
|
16
|
+
data_source_key as data_source_key,
|
|
17
|
+
)
|
|
18
|
+
from harness_toolbox.errors import ErrorKind as ErrorKind
|
|
19
|
+
from harness_toolbox.errors import ToolboxError as ToolboxError
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Private, stdlib-only wire codec, also shipped as source to Pod Python.
|
|
2
|
+
|
|
3
|
+
Every cell is tagged so SQL text cannot be mistaken for a serialized native value.
|
|
4
|
+
Keep this module independent of package imports: Pods need only Python and PyMySQL.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from base64 import b64decode, b64encode
|
|
8
|
+
from datetime import date, datetime, time, timedelta
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def encode_value(value: object) -> list[object]:
|
|
13
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
14
|
+
return ["scalar", value]
|
|
15
|
+
if isinstance(value, bytes):
|
|
16
|
+
return ["bytes", b64encode(value).decode("ascii")]
|
|
17
|
+
if isinstance(value, Decimal):
|
|
18
|
+
return ["decimal", str(value)]
|
|
19
|
+
if isinstance(value, datetime):
|
|
20
|
+
return ["datetime", value.isoformat()]
|
|
21
|
+
if isinstance(value, date):
|
|
22
|
+
return ["date", value.isoformat()]
|
|
23
|
+
if isinstance(value, time):
|
|
24
|
+
return ["time", value.isoformat()]
|
|
25
|
+
if isinstance(value, timedelta):
|
|
26
|
+
return ["timedelta", [value.days, value.seconds, value.microseconds]]
|
|
27
|
+
raise TypeError(f"Unsupported MySQL value type: {type(value).__name__}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def decode_value(cell: list[object]) -> object:
|
|
31
|
+
kind, value = cell
|
|
32
|
+
if kind == "scalar":
|
|
33
|
+
return value
|
|
34
|
+
if kind == "bytes":
|
|
35
|
+
return b64decode(value)
|
|
36
|
+
if kind == "decimal":
|
|
37
|
+
return Decimal(value)
|
|
38
|
+
if kind == "datetime":
|
|
39
|
+
return datetime.fromisoformat(value)
|
|
40
|
+
if kind == "date":
|
|
41
|
+
return date.fromisoformat(value)
|
|
42
|
+
if kind == "time":
|
|
43
|
+
return time.fromisoformat(value)
|
|
44
|
+
if kind == "timedelta":
|
|
45
|
+
return timedelta(*value)
|
|
46
|
+
raise ValueError("Unknown MySQL wire value type")
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Resolve access addresses within the declared environment, without probing services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import ipaddress
|
|
7
|
+
import socket
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
|
|
11
|
+
from harness_common import Environment, KubernetesEnvironment
|
|
12
|
+
|
|
13
|
+
from harness_toolbox.client import ClientProvider, data_source_key
|
|
14
|
+
from harness_toolbox.errors import ErrorKind, ToolboxError
|
|
15
|
+
from harness_toolbox.transport import Endpoint
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AddressResolutionError(ToolboxError):
|
|
19
|
+
"""An address could not be resolved in its declared environment."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AddressPolicy:
|
|
24
|
+
"""Environment and ordered alternatives for one logical connection target.
|
|
25
|
+
|
|
26
|
+
Short names in Kubernetes environments are Services in ``namespace``. Qualified
|
|
27
|
+
Service names use ``name.namespace.svc`` (optionally a cluster DNS suffix).
|
|
28
|
+
Two-label names are Services only when their namespace matches the explicit
|
|
29
|
+
scope. Other domains use DNS; no arbitrary domain is guessed into a Service.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
environment: Environment | None = None
|
|
33
|
+
namespace: str = ""
|
|
34
|
+
fallback_hosts: tuple[str, ...] = ()
|
|
35
|
+
timeout_s: float = 10
|
|
36
|
+
connection_pool_maxsize: int = 8
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def key(self) -> str:
|
|
40
|
+
config = asdict(self)
|
|
41
|
+
# Logical aliases must not fragment clients using the same physical access.
|
|
42
|
+
config["environment"] = (
|
|
43
|
+
{"kubeconfig": self.environment.kubeconfig, "context": self.environment.context}
|
|
44
|
+
if isinstance(self.environment, KubernetesEnvironment)
|
|
45
|
+
else None
|
|
46
|
+
)
|
|
47
|
+
return data_source_key("address-policy", config)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class AddressCandidate:
|
|
52
|
+
endpoint: Endpoint
|
|
53
|
+
source: str
|
|
54
|
+
error: AddressResolutionError | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _service_name(host: str, namespace: str) -> tuple[str, str] | None:
|
|
58
|
+
parts = host.rstrip(".").split(".")
|
|
59
|
+
if len(parts) == 1:
|
|
60
|
+
if not namespace:
|
|
61
|
+
raise ValueError("Kubernetes short Service names require an explicit namespace")
|
|
62
|
+
return host, namespace
|
|
63
|
+
if len(parts) >= 3 and parts[2] == "svc":
|
|
64
|
+
return parts[0], parts[1]
|
|
65
|
+
if len(parts) == 2 and parts[1] == namespace:
|
|
66
|
+
return parts[0], namespace
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _dns(host: str, port: int, timeout_s: float) -> tuple[str, ...]:
|
|
71
|
+
async with asyncio.timeout(timeout_s):
|
|
72
|
+
records = await asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
73
|
+
# Keep resolver preference (including IPv6), but remove duplicate socket records.
|
|
74
|
+
return tuple(dict.fromkeys(record[4][0] for record in records))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _hosts(
|
|
78
|
+
policy: AddressPolicy, endpoint: Endpoint, clients: ClientProvider | None
|
|
79
|
+
) -> tuple[tuple[str, ...], str]:
|
|
80
|
+
try:
|
|
81
|
+
ipaddress.ip_address(endpoint.host)
|
|
82
|
+
except ValueError:
|
|
83
|
+
pass
|
|
84
|
+
else:
|
|
85
|
+
return (endpoint.host,), "ip"
|
|
86
|
+
environment = policy.environment
|
|
87
|
+
service = (
|
|
88
|
+
_service_name(endpoint.host, policy.namespace)
|
|
89
|
+
if isinstance(environment, KubernetesEnvironment)
|
|
90
|
+
else None
|
|
91
|
+
)
|
|
92
|
+
if service is None:
|
|
93
|
+
return await _dns(endpoint.host, endpoint.port, policy.timeout_s), "dns"
|
|
94
|
+
assert isinstance(environment, KubernetesEnvironment)
|
|
95
|
+
if clients is None:
|
|
96
|
+
raise ValueError("Kubernetes address resolution requires a ClientProvider")
|
|
97
|
+
from harness_toolbox.environment import kubernetes_source
|
|
98
|
+
from harness_toolbox.kube import Options
|
|
99
|
+
|
|
100
|
+
name, namespace = service
|
|
101
|
+
from aiohttp import ClientError
|
|
102
|
+
from kubernetes_asyncio.config.config_exception import ConfigException
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
kube = await clients.get(
|
|
106
|
+
kubernetes_source(
|
|
107
|
+
environment,
|
|
108
|
+
Options(
|
|
109
|
+
namespace=namespace,
|
|
110
|
+
request_timeout_s=policy.timeout_s,
|
|
111
|
+
connection_pool_maxsize=policy.connection_pool_maxsize,
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
# why: local short-name DNS can resolve a healthy database in a different cluster.
|
|
116
|
+
# Service identity is authoritative; never fall back to ambient DNS for this name.
|
|
117
|
+
return await kube.service_addresses(name), "kubernetes-service"
|
|
118
|
+
except (ClientError, ConfigException) as error:
|
|
119
|
+
raise AddressResolutionError(
|
|
120
|
+
f"Cannot access Kubernetes environment {environment.name!r} for Service {name!r}",
|
|
121
|
+
kind=ErrorKind.CONNECTION_FAILED,
|
|
122
|
+
) from error
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def address_candidates(
|
|
126
|
+
endpoint: Endpoint, policy: AddressPolicy | None, clients: ClientProvider | None
|
|
127
|
+
) -> AsyncIterator[AddressCandidate]:
|
|
128
|
+
"""Yield ordered candidates; resolution failures remain visible before alternatives.
|
|
129
|
+
|
|
130
|
+
Without a policy, transports keep ownership of name resolution (e.g. a remote
|
|
131
|
+
Pod or a caller-supplied tunnel). No network work occurs while creating a source.
|
|
132
|
+
"""
|
|
133
|
+
if policy is None:
|
|
134
|
+
yield AddressCandidate(endpoint, "configured")
|
|
135
|
+
return
|
|
136
|
+
if policy.timeout_s <= 0 or policy.connection_pool_maxsize < 1:
|
|
137
|
+
raise ValueError("Address resolution limits must be positive")
|
|
138
|
+
seen: set[Endpoint] = set()
|
|
139
|
+
for host in dict.fromkeys((endpoint.host, *policy.fallback_hosts)):
|
|
140
|
+
configured = Endpoint(host, endpoint.port, endpoint.servername or endpoint.host)
|
|
141
|
+
try:
|
|
142
|
+
hosts, source = await _hosts(policy, configured, clients)
|
|
143
|
+
if not hosts:
|
|
144
|
+
raise AddressResolutionError(
|
|
145
|
+
f"No addresses for {host!r}", kind=ErrorKind.RESOURCE_NOT_FOUND
|
|
146
|
+
)
|
|
147
|
+
except (OSError, TimeoutError, ToolboxError) as error:
|
|
148
|
+
kind = (
|
|
149
|
+
error.kind
|
|
150
|
+
if isinstance(error, ToolboxError)
|
|
151
|
+
else (
|
|
152
|
+
ErrorKind.TIMEOUT
|
|
153
|
+
if isinstance(error, TimeoutError)
|
|
154
|
+
else ErrorKind.CONNECTION_FAILED
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
failure = AddressResolutionError(
|
|
158
|
+
f"Resolve {host!r} in environment "
|
|
159
|
+
f"{policy.environment.name if policy.environment else 'local'!r}: {kind.value}",
|
|
160
|
+
kind=kind,
|
|
161
|
+
code=error.code if isinstance(error, ToolboxError) else None,
|
|
162
|
+
)
|
|
163
|
+
failure.__cause__ = error
|
|
164
|
+
yield AddressCandidate(configured, "resolution", failure)
|
|
165
|
+
continue
|
|
166
|
+
for address in hosts:
|
|
167
|
+
resolved = Endpoint(address, endpoint.port, configured.servername)
|
|
168
|
+
if resolved not in seen:
|
|
169
|
+
seen.add(resolved)
|
|
170
|
+
yield AddressCandidate(resolved, source)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Compatibility imports; generic client ownership lives in harness-common."""
|
|
2
|
+
|
|
3
|
+
from harness_common.client import Client as Client
|
|
4
|
+
from harness_common.client import ClientManager as ClientManager
|
|
5
|
+
from harness_common.client import ClientProvider as ClientProvider
|
|
6
|
+
from harness_common.client import DataSource as DataSource
|
|
7
|
+
from harness_common.client import ServiceDataSource as ServiceDataSource
|
|
8
|
+
from harness_common.client import data_source_key as data_source_key
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Connection evidence is client state, separate from the exception contract."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
|
|
6
|
+
from harness_toolbox.errors import ToolboxError
|
|
7
|
+
from harness_toolbox.transport import Endpoint
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _transport_name(transport: object) -> str:
|
|
11
|
+
from harness_toolbox.transport import DirectTransport, PodPythonTransport, PortForwardTransport
|
|
12
|
+
|
|
13
|
+
for kind, name in (
|
|
14
|
+
(PodPythonTransport, "pod-python"),
|
|
15
|
+
(PortForwardTransport, "port-forward"),
|
|
16
|
+
(DirectTransport, "direct"),
|
|
17
|
+
):
|
|
18
|
+
if isinstance(transport, kind):
|
|
19
|
+
return name
|
|
20
|
+
return type(transport).__name__
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _AccessRecorder:
|
|
24
|
+
"""Client-owned state; callers receive detached snapshots."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, protocol: str) -> None:
|
|
27
|
+
self.protocol = protocol
|
|
28
|
+
self.target: dict | None = None
|
|
29
|
+
self.attempts: list[dict] = []
|
|
30
|
+
self.selected_transport: str | None = None
|
|
31
|
+
self.selected_endpoint: dict | None = None
|
|
32
|
+
|
|
33
|
+
def snapshot(self) -> dict:
|
|
34
|
+
return deepcopy(
|
|
35
|
+
{
|
|
36
|
+
"protocol": self.protocol,
|
|
37
|
+
"target": self.target,
|
|
38
|
+
"selected_transport": self.selected_transport,
|
|
39
|
+
"selected_endpoint": self.selected_endpoint,
|
|
40
|
+
"attempts": self.attempts,
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def failed(
|
|
45
|
+
self,
|
|
46
|
+
transport: str,
|
|
47
|
+
error: ToolboxError,
|
|
48
|
+
endpoint: Endpoint | None = None,
|
|
49
|
+
source: str = "configured",
|
|
50
|
+
) -> None:
|
|
51
|
+
self.attempts.append(
|
|
52
|
+
{
|
|
53
|
+
"transport": transport,
|
|
54
|
+
"endpoint": asdict(endpoint) if endpoint else None,
|
|
55
|
+
"source": source,
|
|
56
|
+
"status": "error",
|
|
57
|
+
"error": {"kind": error.kind, "code": error.code, "message": error.message},
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def connected(
|
|
62
|
+
self, transport: str, endpoint: Endpoint | None = None, source: str = "configured"
|
|
63
|
+
) -> None:
|
|
64
|
+
self.selected_endpoint = asdict(endpoint) if endpoint else None
|
|
65
|
+
self.attempts.append(
|
|
66
|
+
{
|
|
67
|
+
"transport": transport,
|
|
68
|
+
"endpoint": self.selected_endpoint,
|
|
69
|
+
"source": source,
|
|
70
|
+
"status": "ok",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
self.selected_transport = transport
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Associate logical runtime identities with independently configured data sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from harness_common.environment import KubernetesEnvironment
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from harness_toolbox.kube import KubernetesDataSource, Options
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def kubernetes_source(environment: KubernetesEnvironment, options: Options) -> KubernetesDataSource:
|
|
14
|
+
"""Adapt environment access facts, with explicit namespace and capacity limits.
|
|
15
|
+
|
|
16
|
+
Logical environment names do not replace physical connection identity.
|
|
17
|
+
"""
|
|
18
|
+
from harness_toolbox.kube import KubernetesDataSource
|
|
19
|
+
|
|
20
|
+
if environment.host is not None:
|
|
21
|
+
environment.host.validate()
|
|
22
|
+
if environment.host.transport == "ssh":
|
|
23
|
+
raise ValueError(
|
|
24
|
+
"KubernetesDataSource requires local cluster access; run the client on "
|
|
25
|
+
"Environment.host via host.command"
|
|
26
|
+
)
|
|
27
|
+
return KubernetesDataSource(
|
|
28
|
+
options,
|
|
29
|
+
kubeconfig=environment.kubeconfig or None,
|
|
30
|
+
context_name=environment.context,
|
|
31
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Public failure contract; protocol adapters own native exception translation."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ErrorKind(StrEnum):
|
|
7
|
+
CONNECTION_FAILED = "connection_failed"
|
|
8
|
+
CONNECTION_LOST = "connection_lost"
|
|
9
|
+
TLS_VERIFICATION_FAILED = "tls_verification_failed"
|
|
10
|
+
AUTHENTICATION_FAILED = "authentication_failed"
|
|
11
|
+
PERMISSION_DENIED = "permission_denied"
|
|
12
|
+
RESOURCE_NOT_FOUND = "resource_not_found"
|
|
13
|
+
TIMEOUT = "timeout"
|
|
14
|
+
LIMIT_EXCEEDED = "limit_exceeded"
|
|
15
|
+
INVALID_RESPONSE = "invalid_response"
|
|
16
|
+
OPERATION_FAILED = "operation_failed"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ToolboxError(Exception):
|
|
20
|
+
"""A safe message, stable kind and optional protocol-native code.
|
|
21
|
+
|
|
22
|
+
why: Consumers catch toolbox types rather than depending on driver exceptions.
|
|
23
|
+
Messages must omit credentials and query payloads; native exceptions belong in
|
|
24
|
+
``__cause__`` for debugging, not in user-facing reports. Serialization and retry
|
|
25
|
+
decisions belong to callers, not to this exception contract.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, *, kind: ErrorKind, code: int | str | None = None) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.message = message
|
|
31
|
+
self.kind = kind
|
|
32
|
+
self.code = code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MySQLError(ToolboxError):
|
|
36
|
+
"""MySQL access failed."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MySQLConnectionError(MySQLError):
|
|
40
|
+
"""Connection acquisition or probing failed, before user SQL execution."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MySQLQueryError(MySQLError):
|
|
44
|
+
"""Query execution or result collection failed; side effects may have occurred."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OpenSearchError(ToolboxError):
|
|
48
|
+
"""OpenSearch access failed."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OpenSearchConnectionError(OpenSearchError):
|
|
52
|
+
"""Connection acquisition or probing failed, before the user request."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OpenSearchRequestError(OpenSearchError):
|
|
56
|
+
"""Request execution or response collection failed."""
|
harness_toolbox/host.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Host observations shared by quality runners and project-owned fixtures."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import shlex
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from harness_common.environment import EnvironmentFacts
|
|
9
|
+
from harness_common.host import Host
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def command(host: Host | None, argv: Sequence[str]) -> list[str]:
|
|
13
|
+
"""Build local/SSH argv for process.execute's bounded I/O and timeout.
|
|
14
|
+
|
|
15
|
+
Remote commands use the host's filesystem, including kubeconfig paths.
|
|
16
|
+
Resource teardown remains the calling fixture's responsibility.
|
|
17
|
+
"""
|
|
18
|
+
if not argv or not argv[0]:
|
|
19
|
+
raise ValueError("host command requires a program")
|
|
20
|
+
if host is not None:
|
|
21
|
+
host.validate()
|
|
22
|
+
if host.transport == "ssh":
|
|
23
|
+
return [
|
|
24
|
+
"ssh",
|
|
25
|
+
"-o",
|
|
26
|
+
"BatchMode=yes",
|
|
27
|
+
"-o",
|
|
28
|
+
"ConnectTimeout=10",
|
|
29
|
+
host.address,
|
|
30
|
+
shlex.join(argv),
|
|
31
|
+
]
|
|
32
|
+
return list(argv)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def observe_local() -> EnvironmentFacts:
|
|
36
|
+
"""Observe this process's host; never use these values for a remote target."""
|
|
37
|
+
os_name = platform.system().lower()
|
|
38
|
+
arch = {"x86_64": "amd64", "aarch64": "arm64"}.get(platform.machine(), platform.machine())
|
|
39
|
+
values = {"os": os_name, "arch": arch, "kernel_version": platform.release()}
|
|
40
|
+
if os_name == "linux":
|
|
41
|
+
distribution = platform.freedesktop_os_release()
|
|
42
|
+
values["distribution"] = distribution.get("ID", "")
|
|
43
|
+
values["distribution_version"] = distribution.get("VERSION_ID", "")
|
|
44
|
+
return EnvironmentFacts("local:uname,os-release", datetime.now(UTC).isoformat(), values)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Namespace-scoped Kubernetes control and observation."""
|
|
2
|
+
|
|
3
|
+
from harness_toolbox.kube.client import KubernetesClient as KubernetesClient
|
|
4
|
+
from harness_toolbox.kube.client import KubernetesDataSource as KubernetesDataSource
|
|
5
|
+
from harness_toolbox.kube.model import Event as Event
|
|
6
|
+
from harness_toolbox.kube.model import Options as Options
|
|
7
|
+
from harness_toolbox.kube.model import Pod as Pod
|
|
8
|
+
from harness_toolbox.kube.model import PodRef as PodRef
|
|
9
|
+
from harness_toolbox.kube.model import PodSpec as PodSpec
|
|
10
|
+
from harness_toolbox.kube.model import ResourceNotFoundError as ResourceNotFoundError
|
|
11
|
+
from harness_toolbox.process import ExecResult as ExecResult
|