unitysvc-data 0.1.0__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.
@@ -0,0 +1,53 @@
1
+ +++
2
+ preset_name = "s3_connectivity"
3
+ category = "connectivity_test"
4
+ mime_type = "python"
5
+ file = "connectivity.py.j2"
6
+ description = "Verify S3 endpoint accepts the configured credentials"
7
+ is_active = true
8
+ is_public = false
9
+ meta = { output_contains = "connectivity ok" }
10
+ +++
11
+
12
+ # s3 / connectivity — S3 credential smoke test
13
+
14
+ Python smoke test that proves **credentials are accepted** by the S3
15
+ endpoint. It does not try to access a specific bucket — the point is
16
+ "can the user authenticate against the upstream", not "does bucket X
17
+ exist" — so AccessDenied on a list call still counts as pass.
18
+
19
+ ## Branches (rendered at upload time with the listing context)
20
+
21
+ | `local_testing` | `interface.access_key` | Behaviour |
22
+ |-----------------|------------------------|-----------|
23
+ | `true` | present | Signs with seller credentials and calls `list_buckets()`. |
24
+ | `true` | absent | Public bucket — prints `connectivity ok` and exits 0 without network call. |
25
+ | `false` | — | Signs with the customer's `UNITYSVC_API_KEY` via the gateway and calls `list_objects_v2(Bucket=..., MaxKeys=1)` against the gateway-resolved bucket. |
26
+
27
+ ## Environment variables (local mode)
28
+
29
+ - `REGION`, `ACCESS_KEY`, `SECRET_KEY` — seller credentials.
30
+ - `S3_ENDPOINT` — optional; set for non-AWS endpoints (DigitalOcean
31
+ Spaces, MinIO, etc.).
32
+
33
+ ## Environment variables (gateway mode)
34
+
35
+ - `SERVICE_BASE_URL` — form: `{endpoint_prefix}/{service_slug}`. The
36
+ script splits on the last `/` to recover `endpoint_url` and `bucket`.
37
+ - `UNITYSVC_API_KEY` — customer's gateway key, used as
38
+ `aws_access_key_id`; `aws_secret_access_key` is set to `"not-used"`
39
+ because the gateway ignores it.
40
+
41
+ ## Auth-failure classification
42
+
43
+ Only these error codes are treated as genuine auth rejection:
44
+ `InvalidAccessKeyId`, `SignatureDoesNotMatch`, `InvalidClientTokenId`,
45
+ and any HTTP 401. Anything else (including 403 AccessDenied) means the
46
+ signature was accepted — that's still connectivity ok.
47
+
48
+ ## Versions
49
+
50
+ ### v1 — initial release
51
+
52
+ - Three-way branch on `local_testing` / `interface.access_key`.
53
+ - Treats non-auth `ClientError` as pass.
@@ -0,0 +1,63 @@
1
+ import boto3
2
+ import os
3
+ import sys
4
+ from botocore.exceptions import BotoCoreError, ClientError
5
+ {% if local_testing %}
6
+ {% if interface.get("access_key") %}
7
+ # Local testing: verify credentials are accepted by the S3 endpoint.
8
+ # We intentionally do NOT target a specific bucket — this test is about
9
+ # "can the user authenticate against the upstream", not "does bucket X
10
+ # exist". list_buckets is the cheapest signed call: credentials are
11
+ # validated regardless of whether the account is actually allowed to
12
+ # enumerate buckets (AccessDenied on the list still proves auth worked).
13
+ s3_kwargs = {
14
+ 'region_name': os.environ['REGION'],
15
+ 'aws_access_key_id': os.environ['ACCESS_KEY'],
16
+ 'aws_secret_access_key': os.environ['SECRET_KEY'],
17
+ }
18
+ if os.environ.get('S3_ENDPOINT'):
19
+ s3_kwargs['endpoint_url'] = os.environ['S3_ENDPOINT']
20
+ s3 = boto3.client('s3', **s3_kwargs)
21
+ {% else %}
22
+ # Public bucket: no credentials to verify, nothing to check.
23
+ print("connectivity ok (public bucket — no credentials to verify)")
24
+ sys.exit(0)
25
+ {% endif %}
26
+ {% else %}
27
+ # Access via UnitySVC S3 gateway — sign with the customer's API key.
28
+ # SERVICE_BASE_URL is {endpoint_prefix}/{service_slug} (e.g. http://host:9180/demo/s3).
29
+ # Split on the last '/' so boto3 path-style addressing reconstructs the full URL.
30
+ service_url = os.environ['SERVICE_BASE_URL'].rstrip('/')
31
+ endpoint_url = service_url.rsplit('/', 1)[0]
32
+ bucket = service_url.rsplit('/', 1)[1]
33
+ s3 = boto3.client('s3',
34
+ endpoint_url=endpoint_url,
35
+ aws_access_key_id=os.environ['UNITYSVC_API_KEY'],
36
+ aws_secret_access_key='not-used',
37
+ region_name='us-east-1',
38
+ )
39
+ {% endif %}
40
+
41
+ # Auth-error codes: credentials were rejected. Anything else (including
42
+ # 403 AccessDenied on list_buckets) still proves the signature was
43
+ # accepted — the caller just isn't allowed to list. That counts as ok.
44
+ AUTH_FAILURES = {'InvalidAccessKeyId', 'SignatureDoesNotMatch', 'InvalidClientTokenId'}
45
+
46
+ try:
47
+ {% if local_testing %}
48
+ s3.list_buckets()
49
+ {% else %}
50
+ s3.list_objects_v2(Bucket=bucket, MaxKeys=1)
51
+ {% endif %}
52
+ except ClientError as e:
53
+ status = e.response.get('ResponseMetadata', {}).get('HTTPStatusCode')
54
+ code = e.response.get('Error', {}).get('Code', '')
55
+ if status == 401 or code in AUTH_FAILURES:
56
+ print(f"connectivity failed: credentials rejected ({code or status})", file=sys.stderr)
57
+ sys.exit(1)
58
+ # Non-auth errors: credentials were accepted, endpoint just didn't
59
+ # like the specific call. That's still connectivity ok.
60
+ except BotoCoreError as e:
61
+ print(f"connectivity failed: {e}", file=sys.stderr)
62
+ sys.exit(1)
63
+ print("connectivity ok")
@@ -0,0 +1,45 @@
1
+ +++
2
+ preset_name = "s3_description"
3
+ category = "description"
4
+ mime_type = "markdown"
5
+ file = "description.md"
6
+ description = "Customer-facing overview of the S3 gateway service"
7
+ is_active = true
8
+ is_public = true
9
+ +++
10
+
11
+ # s3 / description — S3 gateway service description
12
+
13
+ Markdown overview shown to customers on the listing page. Gives them a
14
+ short narrative about the gateway plus a minimal `boto3` snippet they
15
+ can copy-paste.
16
+
17
+ ## What's in the description
18
+
19
+ - One-sentence framing of the gateway.
20
+ - A `boto3.client('s3', ...)` snippet with the four fields a customer
21
+ needs to fill in: `endpoint_url`, `aws_access_key_id`,
22
+ `aws_secret_access_key` (always `"not-used"` for this gateway), and
23
+ the `Bucket` for the `list_objects_v2` call.
24
+ - Closing paragraph explaining how the gateway authenticates, resolves
25
+ the upstream bucket, and proxies the request.
26
+
27
+ ## Template tokens — **not** Jinja2
28
+
29
+ The description file contains `{{ S3_GATEWAY_PUBLIC_URL }}`,
30
+ `{{ API_KEY }}`, and `{{ SERVICE_NAME }}` tokens. These are deliberately
31
+ **not** rendered by the SDK — the file's name is `description.md`, not
32
+ `description.md.j2`, so Pass-2 rendering leaves the tokens intact.
33
+ Customers see them as literal placeholders they're expected to replace
34
+ with their own values.
35
+
36
+ If a future variant wants server-rendered tokens, add it as a new
37
+ `[[versions]]` entry with a `description-v2.md.j2` file alongside the
38
+ existing `description-v1.md` — do not convert `_v1` in place.
39
+
40
+ ## Versions
41
+
42
+ ### v1 — initial release
43
+
44
+ - Static markdown; `{{ ... }}` tokens are literal placeholders shown
45
+ to customers.
@@ -0,0 +1,21 @@
1
+ ## S3 Content via UnitySVC Storage Gateway
2
+
3
+ Access S3-compatible content through UnitySVC's storage gateway using any
4
+ S3 client (boto3, aws-cli, rclone, cyberduck) authenticated with your
5
+ UnitySVC API key.
6
+
7
+ ```python
8
+ import boto3
9
+
10
+ s3 = boto3.client('s3',
11
+ endpoint_url='{{ S3_GATEWAY_PUBLIC_URL }}',
12
+ aws_access_key_id='{{ API_KEY }}',
13
+ aws_secret_access_key='not-used',
14
+ )
15
+
16
+ # Browse available files
17
+ s3.list_objects_v2(Bucket='{{ SERVICE_NAME }}', MaxKeys=10)
18
+ ```
19
+
20
+ The gateway authenticates you against your UnitySVC API key, resolves the
21
+ upstream bucket from the service configuration, and proxies the request.
@@ -0,0 +1,50 @@
1
+ +++
2
+ preset_name = "smtp_connectivity"
3
+ category = "connectivity_test"
4
+ mime_type = "bash"
5
+ file = "connectivity.sh.j2"
6
+ description = "Verify SMTP server returns a 220 greeting on connect"
7
+ is_active = true
8
+ is_public = false
9
+ meta = { output_contains = "connectivity ok" }
10
+ +++
11
+
12
+ # smtp / connectivity — SMTP banner smoke test
13
+
14
+ Bash smoke test for mail-submission services routed through the
15
+ UnitySVC SMTP relay. A healthy server answers a TCP connect with
16
+ `220 ... ESMTP ...` within five seconds; anything else is failure.
17
+
18
+ ## Modes
19
+
20
+ - **Local** — harness sets `$HOST` and `$PORT` from
21
+ `upstream_access_config`. `$PORT` defaults to 587 (submission).
22
+ - **Gateway** — harness sets `$SERVICE_BASE_URL` as `smtp://host:port`
23
+ or `smtps://host:port`. The script strips the scheme, splits host
24
+ from port, and defaults the port to 587 if absent.
25
+
26
+ The script picks the mode by looking at which env var is set — no
27
+ `local_testing` conditional needed.
28
+
29
+ ## How the test works
30
+
31
+ 1. Open a TCP connection via `nc -w 5` (5-second cap on both connect
32
+ and idle-after-banner).
33
+ 2. Send `QUIT` so the server closes cleanly instead of timing out on
34
+ us.
35
+ 3. Read the first line of output.
36
+ 4. Accept any line starting with `220 `.
37
+
38
+ ## Conventions
39
+
40
+ - Success line: `connectivity ok (220 ...)` — `meta.output_contains`
41
+ uses `connectivity ok` so any banner text passes.
42
+ - Does not run `STARTTLS` or authenticate. This test is strictly
43
+ "pipe opens and server says it's SMTP".
44
+ - Ports 25 and 465 work too; override `PORT` in the env.
45
+
46
+ ## Versions
47
+
48
+ ### v1 — initial release
49
+
50
+ - Uses `nc -w 5`; sends `QUIT`; accepts any `220 ...` banner.
@@ -0,0 +1,34 @@
1
+ #!/bin/bash
2
+ # Connectivity test for SMTP services. A healthy mail server sends a
3
+ # "220 ... ESMTP ..." greeting on connect; anything else (no response,
4
+ # timeout, or other greeting code) is treated as failure.
5
+ #
6
+ # Env var layout the harness provides:
7
+ # local mode — $HOST, $PORT (from upstream_access_config)
8
+ # online mode — $SERVICE_BASE_URL (smtp://host:port or smtps://host:port)
9
+ # We branch on whichever is present so the same script works in both.
10
+ set -o pipefail
11
+
12
+ if [ -n "${HOST:-}" ]; then
13
+ PORT="${PORT:-587}"
14
+ else
15
+ URL="${SERVICE_BASE_URL:?need HOST or SERVICE_BASE_URL}"
16
+ stripped="${URL#smtp://}"
17
+ stripped="${stripped#smtps://}"
18
+ HOST="${stripped%%:*}"
19
+ PORT="${stripped#*:}"
20
+ PORT="${PORT%%/*}"
21
+ PORT="${PORT:-587}"
22
+ fi
23
+
24
+ # `nc -w 5` caps both connect wait and idle-after-banner wait. Send QUIT
25
+ # so the server closes cleanly instead of timing out on us.
26
+ greeting=$( (echo "QUIT"; sleep 1) | nc -w 5 "$HOST" "$PORT" 2>/dev/null | head -1 )
27
+
28
+ if [[ "$greeting" == "220 "* ]]; then
29
+ echo "connectivity ok ($greeting)"
30
+ exit 0
31
+ fi
32
+
33
+ echo "connectivity failed: expected '220 ...' greeting from ${HOST}:${PORT}, got: ${greeting:-<no response>}" >&2
34
+ exit 1
@@ -0,0 +1,259 @@
1
+ """Preset document records and file content, backed by bundled example files.
2
+
3
+ Loaded from ``_manifest.json`` at import time so installs don't pay for
4
+ a filesystem walk. The manifest is generated by ``tools/build.py`` from
5
+ the per-family ``README.md`` front-matter; CI validates it's up to
6
+ date on every PR.
7
+
8
+ Two primitives are exposed:
9
+
10
+ - :func:`doc_preset` — returns a fully-populated document record
11
+ (``category``, ``description``, ``mime_type``, ``file_path``, ...),
12
+ suitable for dropping into a seller's ``listing.json`` ``documents``
13
+ map. This is the expansion the SDK applies to ``$preset`` JSON
14
+ sentinels at upload time.
15
+
16
+ - :func:`file_preset` — returns the raw UTF-8 content of the bundled
17
+ example file itself. Useful when the example isn't a listing
18
+ document — for embedding as a code snippet, feeding to a test
19
+ harness, or any future use that needs the bytes rather than the
20
+ document metadata.
21
+
22
+ Both accept either a bare preset name (``"s3_connectivity_v1"``,
23
+ ``"s3_connectivity"``) or a ``{"$preset": ..., "$with": ...}`` sentinel,
24
+ so the same function works from JSON walkers and from programmatic
25
+ Python code. Version-less aliases (``s3_connectivity``) resolve to the
26
+ latest published version in the family.
27
+
28
+ See https://github.com/unitysvc/unitysvc-sellers/issues/25 for design.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ from copy import deepcopy
35
+ from importlib.resources import files as _files
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ # Resolved once at import time. Duplicated with __init__ rather than
40
+ # imported to avoid a circular import during package load.
41
+ _EXAMPLES_ROOT = _files(__name__).joinpath("examples")
42
+
43
+ __all__ = [
44
+ "PRESETS",
45
+ "MANIFEST",
46
+ "ALIASES",
47
+ "OVERRIDABLE",
48
+ "doc_preset",
49
+ "file_preset",
50
+ "list_presets",
51
+ "register_jinja_globals",
52
+ ]
53
+
54
+ # Fields a seller may override when expanding a preset. Everything else
55
+ # — category, mime_type, file_path — is tied to the bundled file
56
+ # content and rejected as an override.
57
+ OVERRIDABLE: frozenset[str] = frozenset({"description", "is_public", "is_active", "meta"})
58
+
59
+
60
+ def _load_manifest() -> dict[str, Any]:
61
+ manifest_bytes = _files(__name__).joinpath("_manifest.json").read_bytes()
62
+ return json.loads(manifest_bytes)
63
+
64
+
65
+ def _resolve_example_path(relative_file: str) -> str:
66
+ target = _EXAMPLES_ROOT.joinpath(relative_file)
67
+ if not target.is_file():
68
+ raise FileNotFoundError(
69
+ f"Manifest references missing file {relative_file!r}. "
70
+ f"Rebuild with `python tools/build.py`."
71
+ )
72
+ return str(Path(str(target)))
73
+
74
+
75
+ MANIFEST: dict[str, Any] = _load_manifest()
76
+ _PRESET_RECORDS: dict[str, dict[str, Any]] = {}
77
+
78
+ for _name, _entry in MANIFEST["presets"].items():
79
+ _PRESET_RECORDS[_name] = {
80
+ "category": _entry["category"],
81
+ "description": _entry["description"],
82
+ "file_path": _resolve_example_path(_entry["example_file"]),
83
+ "is_active": _entry["is_active"],
84
+ "is_public": _entry["is_public"],
85
+ "meta": dict(_entry.get("meta", {})),
86
+ "mime_type": _entry["mime_type"],
87
+ }
88
+
89
+ # Alias entries share the same underlying record as their target. We
90
+ # store the record under both names so a lookup of either resolves in
91
+ # O(1) without chasing pointers.
92
+ ALIASES: dict[str, str] = dict(MANIFEST.get("aliases", {}))
93
+ for _alias, _target in ALIASES.items():
94
+ if _target not in _PRESET_RECORDS:
95
+ raise RuntimeError(
96
+ f"_manifest.json alias {_alias!r} points at missing preset {_target!r}. "
97
+ f"Rebuild with `python tools/build.py`."
98
+ )
99
+ _PRESET_RECORDS[_alias] = _PRESET_RECORDS[_target]
100
+
101
+
102
+ def _make_factory(record: dict[str, Any]):
103
+ def _factory(**overrides: Any) -> dict[str, Any]:
104
+ bad = set(overrides) - OVERRIDABLE
105
+ if bad:
106
+ raise ValueError(
107
+ f"Cannot override {sorted(bad)!r} — these fields are tied to the file content. "
108
+ f"Allowed overrides: {sorted(OVERRIDABLE)!r}"
109
+ )
110
+ out = deepcopy(record)
111
+ for key, value in overrides.items():
112
+ if key == "meta" and isinstance(value, dict) and isinstance(out.get("meta"), dict):
113
+ out["meta"] = {**out["meta"], **value}
114
+ else:
115
+ out[key] = value
116
+ return out
117
+
118
+ return _factory
119
+
120
+
121
+ PRESETS: dict[str, Any] = {name: _make_factory(record) for name, record in _PRESET_RECORDS.items()}
122
+
123
+
124
+ # --- Source parsing --------------------------------------------------------
125
+
126
+
127
+ def _parse_source(source: Any) -> tuple[str, dict[str, Any]]:
128
+ """Normalise a preset reference to ``(name, overrides)``.
129
+
130
+ Accepts either a bare name string or a ``{"$preset": ..., "$with": ...}``
131
+ sentinel dict. Returns ``(name, {})`` for string sources since
132
+ bare-name callers supply overrides as kwargs instead.
133
+ """
134
+ if isinstance(source, str):
135
+ return source, {}
136
+
137
+ if not isinstance(source, dict):
138
+ raise TypeError(
139
+ f"Expected a preset name string or a $preset sentinel dict, "
140
+ f"got {type(source).__name__}"
141
+ )
142
+
143
+ if "$preset" not in source:
144
+ raise ValueError(f"Node is not a preset sentinel (missing '$preset'): {source!r}")
145
+
146
+ unknown = set(source) - {"$preset", "$with"}
147
+ if unknown:
148
+ raise ValueError(
149
+ f"Unknown keys in preset sentinel: {sorted(unknown)!r}. "
150
+ f"Only '$preset' and '$with' are allowed."
151
+ )
152
+
153
+ name = source["$preset"]
154
+ if not isinstance(name, str):
155
+ raise ValueError(f"'$preset' must be a string, got {type(name).__name__}")
156
+
157
+ overrides = source.get("$with") or {}
158
+ if not isinstance(overrides, dict):
159
+ raise ValueError(f"'$with' must be an object, got {type(overrides).__name__}")
160
+
161
+ return name, overrides
162
+
163
+
164
+ def _lookup(name: str, pool: dict[str, Any], what: str) -> Any:
165
+ try:
166
+ return pool[name]
167
+ except KeyError:
168
+ raise KeyError(
169
+ f"Unknown preset: {name!r}. Available {what}s: {sorted(pool)!r}"
170
+ ) from None
171
+
172
+
173
+ # --- Public API ------------------------------------------------------------
174
+
175
+
176
+ def doc_preset(source: Any, **overrides: Any) -> dict[str, Any]:
177
+ """Return a fully-populated document record for the named preset.
178
+
179
+ ``source`` may be a bare preset name (``"s3_connectivity_v1"`` or
180
+ the version-less alias ``"s3_connectivity"``) or a JSON sentinel
181
+ ``{"$preset": ..., "$with": {...}}`` as it appears in a parsed
182
+ ``listing.json``.
183
+
184
+ Overrides (``description``, ``is_active``, ``is_public``, ``meta``)
185
+ may come from ``$with`` when using the sentinel form or from
186
+ keyword arguments when passing a bare name — but not both at once.
187
+ Attempting to override ``category``, ``mime_type``, or ``file_path``
188
+ raises :class:`ValueError` because those are tied to the bundled
189
+ file content.
190
+
191
+ The returned dict is a fresh copy on every call; callers may
192
+ mutate it freely.
193
+ """
194
+ name, sentinel_overrides = _parse_source(source)
195
+ if sentinel_overrides and overrides:
196
+ raise ValueError(
197
+ "Cannot combine keyword overrides with a $preset sentinel's '$with' block — "
198
+ "pick one."
199
+ )
200
+ factory = _lookup(name, PRESETS, "preset")
201
+ return factory(**(sentinel_overrides or overrides))
202
+
203
+
204
+ def file_preset(source: Any) -> str:
205
+ """Return the raw UTF-8 content of the preset's bundled file.
206
+
207
+ ``source`` may be a bare preset name or a ``$preset`` sentinel.
208
+ Overrides are rejected because file content is immutable — use
209
+ :func:`doc_preset` if you need a document record with per-field
210
+ overrides.
211
+
212
+ .. note::
213
+
214
+ For Jinja2 templates (``.j2`` files), the returned string is
215
+ the **raw template source** — constructs like
216
+ ``{% if local_testing %}`` are preserved verbatim. Rendering
217
+ requires listing / interface / provider context that only the
218
+ calling SDK knows; the sellers SDK applies that rendering via
219
+ ``render_template_file`` at upload time. If you're piping the
220
+ content to a runnable file (e.g.
221
+ ``usvc_data file-preset ... > smoke.py``) and the preset
222
+ targets a ``.j2`` file, you'll get a template, not an
223
+ executable script.
224
+ """
225
+ name, sentinel_overrides = _parse_source(source)
226
+ if sentinel_overrides:
227
+ raise ValueError(
228
+ "file_preset does not support '$with' overrides — the file content is immutable. "
229
+ "Use doc_preset() if you need a document record with per-field overrides."
230
+ )
231
+ record = _lookup(name, _PRESET_RECORDS, "preset")
232
+ return Path(record["file_path"]).read_text(encoding="utf-8")
233
+
234
+
235
+ def list_presets() -> tuple[list[str], dict[str, str]]:
236
+ """Return ``(versioned_names, aliases)``.
237
+
238
+ *versioned_names* is the sorted list of concrete preset names
239
+ (``s3_connectivity_v1``, ...); *aliases* maps each version-less
240
+ family name to its current latest versioned name
241
+ (``s3_connectivity`` → ``s3_connectivity_v1``).
242
+
243
+ Symmetric with ``usvc_data list`` on the CLI.
244
+ """
245
+ return sorted(MANIFEST["presets"]), dict(ALIASES)
246
+
247
+
248
+ def register_jinja_globals(env: Any) -> None:
249
+ """Register every preset factory as a Jinja2 global on *env*.
250
+
251
+ Lets generated-repo templates write ``{{ s3_connectivity_v1() | tojson }}``
252
+ directly in ``listing.json.j2`` instead of the ``$preset`` JSON
253
+ sentinel. Hand-authored repos use the sentinel path and never need
254
+ this hook.
255
+
256
+ Both concrete names (``s3_connectivity_v1``) and aliases
257
+ (``s3_connectivity``) are registered.
258
+ """
259
+ env.globals.update(PRESETS)
unitysvc_data/py.typed ADDED
File without changes