datus-dws 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.
- datus_dws-0.1.0/.gitignore +143 -0
- datus_dws-0.1.0/PKG-INFO +123 -0
- datus_dws-0.1.0/README.md +102 -0
- datus_dws-0.1.0/datus_dws/__init__.py +33 -0
- datus_dws-0.1.0/datus_dws/_ca_cert.py +76 -0
- datus_dws-0.1.0/datus_dws/config.py +104 -0
- datus_dws-0.1.0/datus_dws/connector.py +332 -0
- datus_dws-0.1.0/datus_dws/handlers.py +123 -0
- datus_dws-0.1.0/datus_dws/skills/db-dws-sql/SKILL.md +77 -0
- datus_dws-0.1.0/datus_dws/skills.py +27 -0
- datus_dws-0.1.0/pyproject.toml +53 -0
- datus_dws-0.1.0/tests/integration/conftest.py +146 -0
- datus_dws-0.1.0/tests/integration/test_connection_security.py +98 -0
- datus_dws-0.1.0/tests/integration/test_contract.py +99 -0
- datus_dws-0.1.0/tests/integration/test_metadata.py +136 -0
- datus_dws-0.1.0/tests/integration/test_sql_execution.py +124 -0
- datus_dws-0.1.0/tests/unit/test_config.py +132 -0
- datus_dws-0.1.0/tests/unit/test_connector_unit.py +254 -0
- datus_dws-0.1.0/tests/unit/test_handlers.py +127 -0
- datus_dws-0.1.0/tests/unit/test_registration.py +64 -0
- datus_dws-0.1.0/tests/unit/test_skills.py +45 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
pip-wheel-metadata/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
*.manifest
|
|
32
|
+
*.spec
|
|
33
|
+
|
|
34
|
+
# Installer logs
|
|
35
|
+
pip-log.txt
|
|
36
|
+
pip-delete-this-directory.txt
|
|
37
|
+
|
|
38
|
+
# Unit test / coverage reports
|
|
39
|
+
htmlcov/
|
|
40
|
+
.tox/
|
|
41
|
+
.nox/
|
|
42
|
+
.coverage
|
|
43
|
+
.coverage.*
|
|
44
|
+
.cache
|
|
45
|
+
nosetests.xml
|
|
46
|
+
coverage.xml
|
|
47
|
+
*.cover
|
|
48
|
+
*.py,cover
|
|
49
|
+
.hypothesis/
|
|
50
|
+
.pytest_cache/
|
|
51
|
+
|
|
52
|
+
# Translations
|
|
53
|
+
*.mo
|
|
54
|
+
*.pot
|
|
55
|
+
|
|
56
|
+
# Django stuff:
|
|
57
|
+
*.log
|
|
58
|
+
local_settings.py
|
|
59
|
+
db.sqlite3
|
|
60
|
+
db.sqlite3-journal
|
|
61
|
+
|
|
62
|
+
# Flask stuff:
|
|
63
|
+
instance/
|
|
64
|
+
.webassets-cache
|
|
65
|
+
|
|
66
|
+
# Scrapy stuff:
|
|
67
|
+
.scrapy
|
|
68
|
+
|
|
69
|
+
# Sphinx documentation
|
|
70
|
+
docs/_build/
|
|
71
|
+
|
|
72
|
+
# PyBuilder
|
|
73
|
+
target/
|
|
74
|
+
|
|
75
|
+
# Jupyter Notebook
|
|
76
|
+
.ipynb_checkpoints
|
|
77
|
+
|
|
78
|
+
# IPython
|
|
79
|
+
profile_default/
|
|
80
|
+
ipython_config.py
|
|
81
|
+
|
|
82
|
+
# pyenv
|
|
83
|
+
.python-version
|
|
84
|
+
|
|
85
|
+
# pipenv
|
|
86
|
+
Pipfile.lock
|
|
87
|
+
|
|
88
|
+
# uv
|
|
89
|
+
uv.lock
|
|
90
|
+
|
|
91
|
+
# PEP 582
|
|
92
|
+
__pypackages__/
|
|
93
|
+
|
|
94
|
+
# Celery stuff
|
|
95
|
+
celerybeat-schedule
|
|
96
|
+
celerybeat.pid
|
|
97
|
+
|
|
98
|
+
# SageMath parsed files
|
|
99
|
+
*.sage.py
|
|
100
|
+
|
|
101
|
+
# Environments
|
|
102
|
+
.env
|
|
103
|
+
.venv
|
|
104
|
+
env/
|
|
105
|
+
venv/
|
|
106
|
+
ENV/
|
|
107
|
+
env.bak/
|
|
108
|
+
venv.bak/
|
|
109
|
+
|
|
110
|
+
# Spyder project settings
|
|
111
|
+
.spyderproject
|
|
112
|
+
.spyproject
|
|
113
|
+
|
|
114
|
+
# Rope project settings
|
|
115
|
+
.ropeproject
|
|
116
|
+
|
|
117
|
+
# mkdocs documentation
|
|
118
|
+
/site
|
|
119
|
+
|
|
120
|
+
# mypy
|
|
121
|
+
.mypy_cache/
|
|
122
|
+
.dmypy.json
|
|
123
|
+
dmypy.json
|
|
124
|
+
|
|
125
|
+
# Pyre type checker
|
|
126
|
+
.pyre/
|
|
127
|
+
|
|
128
|
+
# IDEs
|
|
129
|
+
.vscode/
|
|
130
|
+
.idea/
|
|
131
|
+
*.swp
|
|
132
|
+
*.swo
|
|
133
|
+
*~
|
|
134
|
+
|
|
135
|
+
# OS
|
|
136
|
+
.DS_Store
|
|
137
|
+
Thumbs.db
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
.omc
|
|
141
|
+
|
|
142
|
+
uv.toml
|
|
143
|
+
*/uv.toml
|
datus_dws-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: datus-dws
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Huawei Cloud GaussDB(DWS) database adapter for Datus
|
|
5
|
+
Project-URL: Homepage, https://github.com/Datus-ai/datus-db-adapters
|
|
6
|
+
Project-URL: Repository, https://github.com/Datus-ai/datus-db-adapters
|
|
7
|
+
Project-URL: Issues, https://github.com/Datus-ai/datus-db-adapters/issues
|
|
8
|
+
Author-email: DatusAI <support@datus.ai>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: adapter,database,datus,dws,gaussdb,huawei,postgresql
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Requires-Dist: datus-db-core>=0.1.6
|
|
18
|
+
Requires-Dist: datus-postgresql>=0.1.8
|
|
19
|
+
Requires-Dist: pydantic>=2.0.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# datus-dws
|
|
23
|
+
|
|
24
|
+
Huawei Cloud GaussDB(DWS) adapter for [Datus](https://github.com/Datus-ai/datus-agent).
|
|
25
|
+
|
|
26
|
+
DWS is a shared-nothing MPP analytical warehouse that speaks the PostgreSQL wire
|
|
27
|
+
protocol. This adapter builds on `datus-postgresql`: it reuses the psycopg2
|
|
28
|
+
transport unchanged and overrides only what DWS does differently — its system
|
|
29
|
+
schema set, its native table-definition function, and the Oracle-compatibility
|
|
30
|
+
semantics of ORA mode.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install datus-dws
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
datasources:
|
|
42
|
+
dws_analytics:
|
|
43
|
+
type: dws
|
|
44
|
+
host: example.dws.myhuaweicloud.com
|
|
45
|
+
port: 8000
|
|
46
|
+
database: gaussdb
|
|
47
|
+
schema: public
|
|
48
|
+
username: dbadmin
|
|
49
|
+
password: ${DWS_PASSWORD}
|
|
50
|
+
sslmode: verify-ca
|
|
51
|
+
sslrootcert: /path/to/cacert.pem
|
|
52
|
+
timeout_seconds: 30
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`host` also accepts the console's `host:port` form, in which case `port` may be
|
|
56
|
+
omitted. The cluster's default database is normally `gaussdb`.
|
|
57
|
+
|
|
58
|
+
## TLS
|
|
59
|
+
|
|
60
|
+
| `sslmode` | Behaviour |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `prefer` (default) | Encrypts when the server offers it; upgrades automatically if the cluster enforces SSL |
|
|
63
|
+
| `require` | Encrypts without verifying the server certificate |
|
|
64
|
+
| `verify-ca` | Verifies the server certificate against `sslrootcert` |
|
|
65
|
+
| `verify-full` | **Cannot succeed** — see below |
|
|
66
|
+
| `disable` | Fails if the cluster has SSL enforcement switched on |
|
|
67
|
+
|
|
68
|
+
Two things to know about DWS certificates:
|
|
69
|
+
|
|
70
|
+
- **`verify-full` is not usable.** The default server certificate has `CN=server`
|
|
71
|
+
and carries no `subjectAltName`, so hostname verification can never match a
|
|
72
|
+
real endpoint. This is a property of the certificate, not a misconfiguration.
|
|
73
|
+
- **Use the v2 CA.** The console's `dws_ssl_cert` bundle contains both
|
|
74
|
+
`v1/sslcert/cacert.pem` and `v2/sslcert/cacert.pem`. Only v2 matches the server
|
|
75
|
+
certificate issuer; v1 is `CN=Huawei Equipment CA` and fails verification.
|
|
76
|
+
|
|
77
|
+
`sslrootcert` accepts either a filesystem path or inline PEM content, so a hosted
|
|
78
|
+
deployment can pass an uploaded certificate directly.
|
|
79
|
+
|
|
80
|
+
## Compatibility modes
|
|
81
|
+
|
|
82
|
+
DWS databases run in `ORA`, `TD` or `MySQL` compatibility mode, reported by
|
|
83
|
+
`pg_database.datcompatibility` and surfaced through
|
|
84
|
+
`DWSConnector.get_compatibility_mode()`.
|
|
85
|
+
|
|
86
|
+
ORA mode — the default for new clusters — changes expression semantics in ways
|
|
87
|
+
that can silently produce wrong results:
|
|
88
|
+
|
|
89
|
+
- `7/2` is `3.5` (double precision), not integer `3`.
|
|
90
|
+
- `''` is stored as NULL and `'' IS NULL` is true; `col = ''` never matches.
|
|
91
|
+
- `'a' || NULL` is `'a'`; concatenation absorbs NULL.
|
|
92
|
+
- `DATE` is `timestamp(0)` and `DATE - DATE` yields an `interval`.
|
|
93
|
+
|
|
94
|
+
TD and MySQL modes are not verified by this adapter's test suite.
|
|
95
|
+
|
|
96
|
+
## Table DDL
|
|
97
|
+
|
|
98
|
+
Table DDL comes from DWS's `pg_get_tabledef()`, which preserves `orientation`,
|
|
99
|
+
`compression`, `DISTRIBUTE BY`, partitioning, `TABLESPACE` and `TO GROUP`.
|
|
100
|
+
|
|
101
|
+
`TABLESPACE` and `TO GROUP` name objects of the source cluster and will not
|
|
102
|
+
replay elsewhere. Use `DWSConnector.strip_cluster_specific_clauses()` to remove
|
|
103
|
+
them before applying the DDL to a migration target.
|
|
104
|
+
|
|
105
|
+
## Testing
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# Unit tests (no cluster required)
|
|
109
|
+
python -m pytest tests/unit/ -v
|
|
110
|
+
|
|
111
|
+
# Integration tests against a live cluster
|
|
112
|
+
export DWS_HOST=... DWS_PORT=8000 DWS_DATABASE=gaussdb \
|
|
113
|
+
DWS_USERNAME=dbadmin DWS_PASSWORD=... DWS_SSLROOTCERT=/path/to/cacert.pem
|
|
114
|
+
python -m pytest tests/integration/ -v
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Integration tests skip automatically when `DWS_HOST` is unset. They create
|
|
118
|
+
objects in a run-scoped schema and drop it on teardown.
|
|
119
|
+
|
|
120
|
+
## Verified against
|
|
121
|
+
|
|
122
|
+
DWS 9.1.0.227, storage-decoupled (compute-group mode), 3 CN + 3 DN, ORA
|
|
123
|
+
compatibility mode.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# datus-dws
|
|
2
|
+
|
|
3
|
+
Huawei Cloud GaussDB(DWS) adapter for [Datus](https://github.com/Datus-ai/datus-agent).
|
|
4
|
+
|
|
5
|
+
DWS is a shared-nothing MPP analytical warehouse that speaks the PostgreSQL wire
|
|
6
|
+
protocol. This adapter builds on `datus-postgresql`: it reuses the psycopg2
|
|
7
|
+
transport unchanged and overrides only what DWS does differently — its system
|
|
8
|
+
schema set, its native table-definition function, and the Oracle-compatibility
|
|
9
|
+
semantics of ORA mode.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install datus-dws
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
```yaml
|
|
20
|
+
datasources:
|
|
21
|
+
dws_analytics:
|
|
22
|
+
type: dws
|
|
23
|
+
host: example.dws.myhuaweicloud.com
|
|
24
|
+
port: 8000
|
|
25
|
+
database: gaussdb
|
|
26
|
+
schema: public
|
|
27
|
+
username: dbadmin
|
|
28
|
+
password: ${DWS_PASSWORD}
|
|
29
|
+
sslmode: verify-ca
|
|
30
|
+
sslrootcert: /path/to/cacert.pem
|
|
31
|
+
timeout_seconds: 30
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`host` also accepts the console's `host:port` form, in which case `port` may be
|
|
35
|
+
omitted. The cluster's default database is normally `gaussdb`.
|
|
36
|
+
|
|
37
|
+
## TLS
|
|
38
|
+
|
|
39
|
+
| `sslmode` | Behaviour |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `prefer` (default) | Encrypts when the server offers it; upgrades automatically if the cluster enforces SSL |
|
|
42
|
+
| `require` | Encrypts without verifying the server certificate |
|
|
43
|
+
| `verify-ca` | Verifies the server certificate against `sslrootcert` |
|
|
44
|
+
| `verify-full` | **Cannot succeed** — see below |
|
|
45
|
+
| `disable` | Fails if the cluster has SSL enforcement switched on |
|
|
46
|
+
|
|
47
|
+
Two things to know about DWS certificates:
|
|
48
|
+
|
|
49
|
+
- **`verify-full` is not usable.** The default server certificate has `CN=server`
|
|
50
|
+
and carries no `subjectAltName`, so hostname verification can never match a
|
|
51
|
+
real endpoint. This is a property of the certificate, not a misconfiguration.
|
|
52
|
+
- **Use the v2 CA.** The console's `dws_ssl_cert` bundle contains both
|
|
53
|
+
`v1/sslcert/cacert.pem` and `v2/sslcert/cacert.pem`. Only v2 matches the server
|
|
54
|
+
certificate issuer; v1 is `CN=Huawei Equipment CA` and fails verification.
|
|
55
|
+
|
|
56
|
+
`sslrootcert` accepts either a filesystem path or inline PEM content, so a hosted
|
|
57
|
+
deployment can pass an uploaded certificate directly.
|
|
58
|
+
|
|
59
|
+
## Compatibility modes
|
|
60
|
+
|
|
61
|
+
DWS databases run in `ORA`, `TD` or `MySQL` compatibility mode, reported by
|
|
62
|
+
`pg_database.datcompatibility` and surfaced through
|
|
63
|
+
`DWSConnector.get_compatibility_mode()`.
|
|
64
|
+
|
|
65
|
+
ORA mode — the default for new clusters — changes expression semantics in ways
|
|
66
|
+
that can silently produce wrong results:
|
|
67
|
+
|
|
68
|
+
- `7/2` is `3.5` (double precision), not integer `3`.
|
|
69
|
+
- `''` is stored as NULL and `'' IS NULL` is true; `col = ''` never matches.
|
|
70
|
+
- `'a' || NULL` is `'a'`; concatenation absorbs NULL.
|
|
71
|
+
- `DATE` is `timestamp(0)` and `DATE - DATE` yields an `interval`.
|
|
72
|
+
|
|
73
|
+
TD and MySQL modes are not verified by this adapter's test suite.
|
|
74
|
+
|
|
75
|
+
## Table DDL
|
|
76
|
+
|
|
77
|
+
Table DDL comes from DWS's `pg_get_tabledef()`, which preserves `orientation`,
|
|
78
|
+
`compression`, `DISTRIBUTE BY`, partitioning, `TABLESPACE` and `TO GROUP`.
|
|
79
|
+
|
|
80
|
+
`TABLESPACE` and `TO GROUP` name objects of the source cluster and will not
|
|
81
|
+
replay elsewhere. Use `DWSConnector.strip_cluster_specific_clauses()` to remove
|
|
82
|
+
them before applying the DDL to a migration target.
|
|
83
|
+
|
|
84
|
+
## Testing
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Unit tests (no cluster required)
|
|
88
|
+
python -m pytest tests/unit/ -v
|
|
89
|
+
|
|
90
|
+
# Integration tests against a live cluster
|
|
91
|
+
export DWS_HOST=... DWS_PORT=8000 DWS_DATABASE=gaussdb \
|
|
92
|
+
DWS_USERNAME=dbadmin DWS_PASSWORD=... DWS_SSLROOTCERT=/path/to/cacert.pem
|
|
93
|
+
python -m pytest tests/integration/ -v
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Integration tests skip automatically when `DWS_HOST` is unset. They create
|
|
97
|
+
objects in a run-scoped schema and drop it on teardown.
|
|
98
|
+
|
|
99
|
+
## Verified against
|
|
100
|
+
|
|
101
|
+
DWS 9.1.0.227, storage-decoupled (compute-group mode), 3 CN + 3 DN, ORA
|
|
102
|
+
compatibility mode.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright 2025-present DatusAI, Inc.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
|
+
|
|
5
|
+
from .config import DWSConfig
|
|
6
|
+
from .connector import DWSConnector
|
|
7
|
+
from .handlers import (
|
|
8
|
+
build_dws_uri,
|
|
9
|
+
parse_dws_identifier,
|
|
10
|
+
resolve_dws_context,
|
|
11
|
+
)
|
|
12
|
+
from .skills import get_dws_sql_generation_notes
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
__all__ = ["DWSConnector", "DWSConfig", "register"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register():
|
|
19
|
+
"""Register DWS and its generic Agent integration hooks."""
|
|
20
|
+
from datus_db_core import connector_registry
|
|
21
|
+
|
|
22
|
+
connector_registry.register(
|
|
23
|
+
"dws",
|
|
24
|
+
DWSConnector,
|
|
25
|
+
config_class=DWSConfig,
|
|
26
|
+
display_name="Huawei DWS",
|
|
27
|
+
capabilities={"database", "schema"},
|
|
28
|
+
uri_builder=build_dws_uri,
|
|
29
|
+
context_resolver=resolve_dws_context,
|
|
30
|
+
parser_dialect="postgres",
|
|
31
|
+
identifier_parser=parse_dws_identifier,
|
|
32
|
+
sql_generation_notes=get_dws_sql_generation_notes,
|
|
33
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright 2025-present DatusAI, Inc.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
|
+
|
|
5
|
+
"""Accept a CA certificate as inline PEM, not only as a path on disk.
|
|
6
|
+
|
|
7
|
+
A hosted caller uploads the certificate through a browser, so what reaches the
|
|
8
|
+
adapter is the file's *content*: there is no operator to drop a file on the
|
|
9
|
+
server first, and on a multi-tenant runtime there is nowhere stable to drop it.
|
|
10
|
+
|
|
11
|
+
psycopg2 takes a filename rather than bytes, so inline PEM is spilled to a
|
|
12
|
+
private temp file, once per distinct certificate and reused thereafter.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import atexit
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
from typing import Dict, Optional
|
|
21
|
+
|
|
22
|
+
from datus_db_core import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
_PEM_MARKER = "-----BEGIN CERTIFICATE-----"
|
|
27
|
+
|
|
28
|
+
_lock = threading.Lock()
|
|
29
|
+
_materialized: Dict[str, str] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_inline_pem(value: Optional[str]) -> bool:
|
|
33
|
+
"""Whether *value* is certificate content rather than a path to one."""
|
|
34
|
+
return bool(value) and _PEM_MARKER in value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def as_path(value: Optional[str]) -> Optional[str]:
|
|
38
|
+
"""Return a filesystem path for *value*, writing it out if it is inline PEM.
|
|
39
|
+
|
|
40
|
+
Paths pass through untouched, so a self-hosted deployment that mounts its
|
|
41
|
+
CA file keeps working exactly as before.
|
|
42
|
+
"""
|
|
43
|
+
if not is_inline_pem(value):
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
47
|
+
with _lock:
|
|
48
|
+
path = _materialized.get(digest)
|
|
49
|
+
if path and os.path.exists(path):
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
fd, path = tempfile.mkstemp(prefix=f"datus-dws-ca-{digest[:12]}-", suffix=".pem")
|
|
53
|
+
try:
|
|
54
|
+
# 0600 before the bytes land: a world-readable trust anchor is an
|
|
55
|
+
# invitation to swap it.
|
|
56
|
+
os.fchmod(fd, 0o600)
|
|
57
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
58
|
+
handle.write(value)
|
|
59
|
+
except BaseException:
|
|
60
|
+
os.unlink(path)
|
|
61
|
+
raise
|
|
62
|
+
|
|
63
|
+
_materialized[digest] = path
|
|
64
|
+
logger.debug("Materialized inline DWS CA certificate to %s", path)
|
|
65
|
+
return path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@atexit.register
|
|
69
|
+
def _cleanup() -> None:
|
|
70
|
+
with _lock:
|
|
71
|
+
for path in _materialized.values():
|
|
72
|
+
try:
|
|
73
|
+
os.unlink(path)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
_materialized.clear()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Copyright 2025-present DatusAI, Inc.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, Optional
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, model_validator
|
|
9
|
+
|
|
10
|
+
from datus_postgresql import PostgreSQLConfig
|
|
11
|
+
|
|
12
|
+
DEFAULT_DWS_PORT = 8000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_dws_endpoint(host: str, port: Any = None) -> tuple[str, int]:
|
|
16
|
+
"""Normalize a console endpoint into separate host and port values.
|
|
17
|
+
|
|
18
|
+
The DWS console presents the public endpoint as ``host:port``, so accept
|
|
19
|
+
that form directly rather than making the user split it by hand.
|
|
20
|
+
"""
|
|
21
|
+
endpoint = str(host or "").strip()
|
|
22
|
+
if not endpoint:
|
|
23
|
+
raise ValueError("DWS host is required")
|
|
24
|
+
if "://" in endpoint:
|
|
25
|
+
raise ValueError("DWS host must not include a URI scheme")
|
|
26
|
+
|
|
27
|
+
# A bare IPv6 literal has to be bracketed before urlsplit will read it as a
|
|
28
|
+
# host rather than a host:port pair. Config keeps host and port in separate
|
|
29
|
+
# fields, so users write the address unbracketed.
|
|
30
|
+
if endpoint.count(":") > 1 and not endpoint.startswith("["):
|
|
31
|
+
endpoint = f"[{endpoint}]"
|
|
32
|
+
|
|
33
|
+
parsed = urlsplit(f"//{endpoint}")
|
|
34
|
+
if parsed.username is not None or parsed.password is not None:
|
|
35
|
+
raise ValueError("DWS host must not contain user information")
|
|
36
|
+
if parsed.path or parsed.query or parsed.fragment:
|
|
37
|
+
raise ValueError("DWS host must contain only a hostname and optional port")
|
|
38
|
+
if not parsed.hostname:
|
|
39
|
+
raise ValueError("DWS host is invalid")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
embedded_port = parsed.port
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
raise ValueError(f"Invalid DWS endpoint: {endpoint}") from exc
|
|
45
|
+
|
|
46
|
+
explicit_port = None
|
|
47
|
+
if port is not None and str(port).strip():
|
|
48
|
+
try:
|
|
49
|
+
explicit_port = int(port)
|
|
50
|
+
except (TypeError, ValueError) as exc:
|
|
51
|
+
raise ValueError(f"Invalid DWS port: {port}") from exc
|
|
52
|
+
|
|
53
|
+
if embedded_port is not None and explicit_port is not None and embedded_port != explicit_port:
|
|
54
|
+
raise ValueError(f"DWS endpoint port {embedded_port} conflicts with explicit port {explicit_port}")
|
|
55
|
+
|
|
56
|
+
effective_port = embedded_port if embedded_port is not None else explicit_port
|
|
57
|
+
effective_port = DEFAULT_DWS_PORT if effective_port is None else effective_port
|
|
58
|
+
if not 1 <= effective_port <= 65535:
|
|
59
|
+
raise ValueError(f"DWS port must be between 1 and 65535: {effective_port}")
|
|
60
|
+
return parsed.hostname, effective_port
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DWSConfig(PostgreSQLConfig):
|
|
64
|
+
"""Connection configuration for Huawei Cloud GaussDB(DWS)."""
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="before")
|
|
67
|
+
@classmethod
|
|
68
|
+
def normalize_endpoint(cls, values):
|
|
69
|
+
if not isinstance(values, dict) or "host" not in values:
|
|
70
|
+
return values
|
|
71
|
+
normalized = dict(values)
|
|
72
|
+
host, port = normalize_dws_endpoint(normalized.get("host"), normalized.get("port"))
|
|
73
|
+
normalized["host"] = host
|
|
74
|
+
normalized["port"] = port
|
|
75
|
+
return normalized
|
|
76
|
+
|
|
77
|
+
host: str = Field(..., min_length=1, description="DWS coordinator endpoint, optionally with a port")
|
|
78
|
+
port: int = Field(default=DEFAULT_DWS_PORT, ge=1, le=65535, description="DWS coordinator port")
|
|
79
|
+
username: str = Field(..., min_length=1, description="DWS database user")
|
|
80
|
+
password: str = Field(
|
|
81
|
+
default="",
|
|
82
|
+
repr=False,
|
|
83
|
+
description="DWS password",
|
|
84
|
+
json_schema_extra={"input_type": "password"},
|
|
85
|
+
)
|
|
86
|
+
database: str = Field(..., min_length=1, description="DWS database name (the cluster default is 'gaussdb')")
|
|
87
|
+
schema_name: str = Field(default="public", alias="schema", min_length=1, description="Default schema name")
|
|
88
|
+
sslmode: Literal["disable", "allow", "prefer", "require", "verify-ca", "verify-full"] = Field(
|
|
89
|
+
default="prefer",
|
|
90
|
+
description=(
|
|
91
|
+
"PostgreSQL SSL mode. 'prefer' upgrades automatically when the cluster enforces SSL. "
|
|
92
|
+
"'verify-full' cannot succeed against the DWS default server certificate, whose CN is "
|
|
93
|
+
"'server' and which carries no subjectAltName"
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
sslrootcert: Optional[str] = Field(
|
|
97
|
+
default=None,
|
|
98
|
+
description=(
|
|
99
|
+
"CA certificate for sslmode=verify-ca, as a file path or inline PEM content. "
|
|
100
|
+
"Use v2/sslcert/cacert.pem from the console's dws_ssl_cert bundle; the v1 CA does "
|
|
101
|
+
"not match the server certificate issuer"
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
timeout_seconds: int = Field(default=30, gt=0, description="Connection and pool timeout in seconds")
|