atlan-application-sdk-api 3.51.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.
- application_sdk/__init__.py +7 -0
- application_sdk/_build_info.py +62 -0
- application_sdk/_context_errors.py +65 -0
- application_sdk/_install.py +130 -0
- application_sdk/_logging.py +171 -0
- application_sdk/_runtime/__init__.py +29 -0
- application_sdk/_runtime/offload.py +1020 -0
- application_sdk/_runtime/progress.py +757 -0
- application_sdk/clients/__init__.py +63 -0
- application_sdk/clients/_interface.py +58 -0
- application_sdk/clients/_sql_failure_classifier.py +112 -0
- application_sdk/clients/base.py +565 -0
- application_sdk/clients/http_errors.py +28 -0
- application_sdk/clients/models.py +49 -0
- application_sdk/clients/sql.py +1218 -0
- application_sdk/clients/sql_errors.py +192 -0
- application_sdk/clients/sql_typecasters.py +431 -0
- application_sdk/clients/ssl_utils.py +247 -0
- application_sdk/common/__init__.py +53 -0
- application_sdk/common/_generated_tree.py +321 -0
- application_sdk/common/_listing.py +192 -0
- application_sdk/common/aws_utils.py +522 -0
- application_sdk/common/aws_utils_errors.py +88 -0
- application_sdk/common/build_identity.py +120 -0
- application_sdk/common/concurrency.py +45 -0
- application_sdk/common/errors.py +99 -0
- application_sdk/common/filter_matching.py +261 -0
- application_sdk/common/logger.py +11 -0
- application_sdk/common/models.py +31 -0
- application_sdk/common/sql_filters.py +719 -0
- application_sdk/common/sql_filters_errors.py +17 -0
- application_sdk/common/task_queue.py +431 -0
- application_sdk/common/types.py +51 -0
- application_sdk/common/utils.py +87 -0
- application_sdk/constants.py +1120 -0
- application_sdk/contracts/__init__.py +158 -0
- application_sdk/contracts/base.py +1178 -0
- application_sdk/contracts/events.py +251 -0
- application_sdk/contracts/types.py +801 -0
- application_sdk/contracts/types_errors.py +17 -0
- application_sdk/credentials/__init__.py +326 -0
- application_sdk/credentials/errors.py +226 -0
- application_sdk/credentials/extra.py +114 -0
- application_sdk/credentials/ingress.py +283 -0
- application_sdk/credentials/ref.py +376 -0
- application_sdk/credentials/spec.py +182 -0
- application_sdk/credentials/types.py +339 -0
- application_sdk/credentials/utils.py +257 -0
- application_sdk/errors/__init__.py +224 -0
- application_sdk/errors/base.py +504 -0
- application_sdk/errors/categories.py +118 -0
- application_sdk/errors/http.py +81 -0
- application_sdk/errors/leaves.py +526 -0
- application_sdk/errors/wire.py +124 -0
- application_sdk/handler/__init__.py +97 -0
- application_sdk/handler/_warmup.py +198 -0
- application_sdk/handler/asgi.py +252 -0
- application_sdk/handler/base.py +281 -0
- application_sdk/handler/context.py +212 -0
- application_sdk/handler/contracts.py +1166 -0
- application_sdk/handler/manifest.py +81 -0
- application_sdk/handler/metadata_routes.py +699 -0
- application_sdk/handler/object_routes.py +418 -0
- application_sdk/handler/request_contract.py +91 -0
- application_sdk/handler/routes.py +1118 -0
- application_sdk/handler/service_errors.py +37 -0
- application_sdk/server/__init__.py +15 -0
- application_sdk/server/fastapi/models.py +338 -0
- application_sdk/templates/__init__.py +52 -0
- application_sdk/templates/contracts/__init__.py +111 -0
- application_sdk/templates/contracts/base_metadata_extraction.py +32 -0
- application_sdk/templates/contracts/incremental_sql.py +563 -0
- application_sdk/templates/contracts/sql_metadata.py +824 -0
- application_sdk/templates/contracts/sql_query.py +105 -0
- application_sdk/version.py +40 -0
- atlan_application_sdk_api-3.51.0.dist-info/METADATA +230 -0
- atlan_application_sdk_api-3.51.0.dist-info/RECORD +78 -0
- atlan_application_sdk_api-3.51.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
"""The metadata routes, served from an app's generated contract files.
|
|
2
|
+
|
|
3
|
+
``/workflows/v1/configmap/{id}``, ``/workflows/v1/configmaps``, the manifest
|
|
4
|
+
(``/workflows/v1/manifest`` and the unversioned ``/manifest``, GET and POST) and
|
|
5
|
+
``/workflows/v1/input-contract`` read only the files the contract toolkit
|
|
6
|
+
generates into ``app/generated/``. The worker's handler service and the
|
|
7
|
+
consolidated API host (:func:`application_sdk.handler.asgi.build_asgi_app`)
|
|
8
|
+
both register them through :func:`register_metadata_routes`, so an app answers
|
|
9
|
+
the same from either place.
|
|
10
|
+
|
|
11
|
+
The two surfaces differ only in what a :class:`MetadataSource` resolves:
|
|
12
|
+
|
|
13
|
+
* the worker resolves entry points from its app registry, stamps the manifest
|
|
14
|
+
with the task queue its own worker polls, and runs an app's
|
|
15
|
+
``compute_manifest`` hooks;
|
|
16
|
+
* the host has no app registry. It resolves entry points from the generated
|
|
17
|
+
tree, derives the task queue from the app's name and deployment exactly as the
|
|
18
|
+
worker does, and serves static manifests only: ``gen_app_api.py check``
|
|
19
|
+
refuses an app with a ``compute_manifest`` hook, so no hooked app reaches it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import importlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from collections.abc import Awaitable, Callable
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, cast
|
|
31
|
+
|
|
32
|
+
import orjson
|
|
33
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
34
|
+
from fastapi.responses import JSONResponse, Response
|
|
35
|
+
|
|
36
|
+
from application_sdk._logging import get_logger
|
|
37
|
+
from application_sdk.common._generated_tree import (
|
|
38
|
+
MANIFEST_STEM,
|
|
39
|
+
choose_form_configmap,
|
|
40
|
+
eligible_form_configmaps,
|
|
41
|
+
names_entrypoint,
|
|
42
|
+
)
|
|
43
|
+
from application_sdk.common.build_identity import (
|
|
44
|
+
BUILD_IDENTITY_CONFIGMAP_ID,
|
|
45
|
+
build_identity,
|
|
46
|
+
)
|
|
47
|
+
from application_sdk.common.task_queue import resolve_manifest_tokens
|
|
48
|
+
from application_sdk.handler.routes import (
|
|
49
|
+
_ENTRYPOINT_NAME_RE,
|
|
50
|
+
_wrap_response,
|
|
51
|
+
entrypoint_module_segment,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Named for application_sdk.handler.service, not this module: these routes moved
|
|
55
|
+
# from there and keep its logger name (the worker also passes that module's
|
|
56
|
+
# logger as ``MetadataSource.log``), so their log lines are unchanged.
|
|
57
|
+
logger = get_logger("application_sdk.handler.service")
|
|
58
|
+
|
|
59
|
+
# Heracles URL-encodes the whole setup-form payload into ``fe_inputs`` on the
|
|
60
|
+
# GET. A typical form is ~1.6 KB decoded and a heavy multi-select (lists of
|
|
61
|
+
# qualified names / GUIDs / tags) is ~5 KB — so 8 KB leaves headroom without
|
|
62
|
+
# rejecting legitimate forms. Larger payloads use the POST body.
|
|
63
|
+
MAX_FE_INPUTS_BYTES = 8192
|
|
64
|
+
|
|
65
|
+
#: ``async def compute_manifest(manifest: dict, fe_inputs: dict) -> dict``.
|
|
66
|
+
ComputeManifest = Callable[[dict[str, Any], dict[str, Any]], Awaitable[Any]]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def decode_fe_inputs(
|
|
70
|
+
raw: str | None, max_bytes: int = MAX_FE_INPUTS_BYTES
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""Decode the ``fe_inputs`` query payload (JSON, sent URL-encoded by
|
|
73
|
+
Heracles for dynamic-manifest apps). Returns ``{}`` when absent; raises
|
|
74
|
+
``HTTPException(413)`` when it exceeds ``max_bytes`` and
|
|
75
|
+
``HTTPException(400)`` when present but malformed."""
|
|
76
|
+
if not raw:
|
|
77
|
+
return {}
|
|
78
|
+
if len(raw.encode("utf-8")) > max_bytes:
|
|
79
|
+
raise HTTPException(
|
|
80
|
+
status_code=413,
|
|
81
|
+
detail=f"fe_inputs exceeds the {max_bytes}-byte limit",
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
# orjson.JSONDecodeError subclasses json.JSONDecodeError.
|
|
85
|
+
parsed = orjson.loads(raw)
|
|
86
|
+
except json.JSONDecodeError as exc:
|
|
87
|
+
# A fixed message: no parser or exception text in a response body.
|
|
88
|
+
raise HTTPException(
|
|
89
|
+
status_code=400, detail="fe_inputs is not valid JSON"
|
|
90
|
+
) from exc
|
|
91
|
+
if not isinstance(parsed, dict):
|
|
92
|
+
raise HTTPException(status_code=400, detail="fe_inputs must be a JSON object")
|
|
93
|
+
return parsed
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def entrypoint_form(
|
|
97
|
+
generated_dir: Path, entrypoint: str
|
|
98
|
+
) -> tuple[Path | None, list[Path]]:
|
|
99
|
+
"""The generated setup form served for *entrypoint*, and the files it was
|
|
100
|
+
chosen from.
|
|
101
|
+
|
|
102
|
+
Searches both generated layouts: multi-entrypoint apps nest each form under
|
|
103
|
+
``<generated_dir>/<entrypoint>/``, single-entrypoint apps emit it flat in
|
|
104
|
+
``generated_dir``. The nested directory wins. Within a directory
|
|
105
|
+
:func:`choose_form_configmap` decides. ``(None, [])`` when no eligible form
|
|
106
|
+
exists.
|
|
107
|
+
"""
|
|
108
|
+
for search_dir in (generated_dir / entrypoint, generated_dir):
|
|
109
|
+
candidates = eligible_form_configmaps(search_dir)
|
|
110
|
+
target = choose_form_configmap(candidates, entrypoint)
|
|
111
|
+
if target is not None:
|
|
112
|
+
return target, candidates
|
|
113
|
+
return None, []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def tree_entrypoints(generated_dir: Path) -> list[str]:
|
|
117
|
+
"""The entry points the generated tree has a manifest for, sorted.
|
|
118
|
+
|
|
119
|
+
Each ``<generated_dir>/<entrypoint>/manifest.json`` is one (the
|
|
120
|
+
multi-entry-point layout). Read from disk: what the host knows of an app's
|
|
121
|
+
entry points without its registry.
|
|
122
|
+
"""
|
|
123
|
+
if not generated_dir.is_dir():
|
|
124
|
+
return []
|
|
125
|
+
return sorted(p.parent.name for p in generated_dir.glob("*/manifest.json"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def tree_default_entrypoint(generated_dir: Path) -> str | None:
|
|
129
|
+
"""The entry point a request that names none resolves to, from disk.
|
|
130
|
+
|
|
131
|
+
A single-entry-point app (flat ``manifest.json`` or a flat form) resolves
|
|
132
|
+
to ``""``, the app-level entry point. A multi-entry-point app resolves only
|
|
133
|
+
when it has exactly one; otherwise there is no default, as on the worker
|
|
134
|
+
when the builder marks none.
|
|
135
|
+
"""
|
|
136
|
+
if not generated_dir.is_dir():
|
|
137
|
+
return None
|
|
138
|
+
if (generated_dir / "manifest.json").is_file() or eligible_form_configmaps(
|
|
139
|
+
generated_dir
|
|
140
|
+
):
|
|
141
|
+
return ""
|
|
142
|
+
nested = tree_entrypoints(generated_dir)
|
|
143
|
+
return nested[0] if len(nested) == 1 else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def tree_input_contract(
|
|
147
|
+
generated_dir: Path, app_package: str, entrypoint: str | None
|
|
148
|
+
) -> Any:
|
|
149
|
+
"""The generated ``AppInputContract`` for *entrypoint*, resolved from disk.
|
|
150
|
+
|
|
151
|
+
Looks where the contract toolkit writes it: ``generated/<ep>/_input.py``
|
|
152
|
+
(or the bundle relocation ``<package>/<ep>/_input.py``) for a nested entry
|
|
153
|
+
point, ``generated/_input.py`` for the app-level one. Raises
|
|
154
|
+
``HTTPException(404)`` when there is none.
|
|
155
|
+
"""
|
|
156
|
+
default = tree_default_entrypoint(generated_dir)
|
|
157
|
+
selected = default if entrypoint is None else entrypoint
|
|
158
|
+
if selected is None:
|
|
159
|
+
raise HTTPException(
|
|
160
|
+
status_code=400,
|
|
161
|
+
detail="This app has several entry points; pass ?entrypoint=.",
|
|
162
|
+
)
|
|
163
|
+
nested = selected and (generated_dir / selected / "manifest.json").is_file()
|
|
164
|
+
if nested:
|
|
165
|
+
segment = entrypoint_module_segment(selected)
|
|
166
|
+
candidates = (
|
|
167
|
+
f"{app_package}.generated.{segment}._input",
|
|
168
|
+
f"{app_package}.{segment}._input",
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
candidates = (f"{app_package}.generated._input",)
|
|
172
|
+
for module_path in candidates:
|
|
173
|
+
try:
|
|
174
|
+
module = importlib.import_module(module_path)
|
|
175
|
+
except ImportError: # conformance: ignore[E008,E014] optional generated module; continue to next candidate
|
|
176
|
+
continue
|
|
177
|
+
contract = getattr(module, "AppInputContract", None)
|
|
178
|
+
if contract is not None and hasattr(contract, "model_json_schema"):
|
|
179
|
+
return contract
|
|
180
|
+
raise HTTPException(status_code=404, detail="Entry point not found")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class MetadataSource:
|
|
185
|
+
"""What the metadata routes read for one app.
|
|
186
|
+
|
|
187
|
+
Every attribute that can change at runtime is a callable, read per
|
|
188
|
+
request, so a process that reconfigures (or a test that repoints the
|
|
189
|
+
generated dir) is seen without re-registering the routes.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
generated_dir: The app's generated contract directory.
|
|
193
|
+
app_name: The app's name: the build-identity configmap reports it, and
|
|
194
|
+
the manifest's ``{app_name}`` token is filled from it.
|
|
195
|
+
app_package: The import root the generated ``_input.py`` modules live
|
|
196
|
+
under (``app`` on the worker, ``atlan_<app>_api`` on the host).
|
|
197
|
+
task_queue: The queue the app's worker polls, stamped into the served
|
|
198
|
+
manifest. ``None`` derives it from the app name and deployment, as
|
|
199
|
+
the worker itself does.
|
|
200
|
+
deployment_name: Fallback for the manifest's ``{deployment_name}``.
|
|
201
|
+
fallback_app_name: Fills ``{app_name}`` when ``app_name()`` is empty.
|
|
202
|
+
default_entrypoint: Entry point a request that names none resolves to,
|
|
203
|
+
raising ``HTTPException`` when there is none; ``None`` reads it
|
|
204
|
+
from the generated tree.
|
|
205
|
+
explicit_entrypoints: Entry points to try, in order, when the default
|
|
206
|
+
has no manifest; ``None`` reads them from the generated tree.
|
|
207
|
+
input_contract: Resolves ``?entrypoint=`` to its input model, raising
|
|
208
|
+
``HTTPException``; ``None`` resolves from the generated tree.
|
|
209
|
+
compute_manifest: The entry point's ``compute_manifest`` hook, or
|
|
210
|
+
``None``.
|
|
211
|
+
programmatic_manifest: An ``AppManifest`` built in Python, served when
|
|
212
|
+
no entry point is named.
|
|
213
|
+
log: The logger the routes report through.
|
|
214
|
+
max_fe_inputs_bytes: The GET ``fe_inputs`` cap.
|
|
215
|
+
from_process_env: Whether this process's own ``ATLAN_APPLICATION_NAME``
|
|
216
|
+
/ ``ATLAN_DEPLOYMENT_NAME`` describe this app. ``False`` on the
|
|
217
|
+
consolidated API host, where they describe the host.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
generated_dir: Callable[[], Path]
|
|
221
|
+
app_name: Callable[[], str]
|
|
222
|
+
app_package: str = "app"
|
|
223
|
+
task_queue: Callable[[], str | None] = lambda: None
|
|
224
|
+
deployment_name: Callable[[], str] = lambda: ""
|
|
225
|
+
fallback_app_name: str = ""
|
|
226
|
+
default_entrypoint: Callable[[], str] | None = None
|
|
227
|
+
explicit_entrypoints: Callable[[], list[str]] | None = None
|
|
228
|
+
input_contract: Callable[[str | None], Any] | None = None
|
|
229
|
+
compute_manifest: Callable[[str], ComputeManifest | None] = field(
|
|
230
|
+
default=lambda _entrypoint: None
|
|
231
|
+
)
|
|
232
|
+
programmatic_manifest: Any = None
|
|
233
|
+
log: Callable[[], Any] = lambda: logger
|
|
234
|
+
max_fe_inputs_bytes: Callable[[], int] = lambda: MAX_FE_INPUTS_BYTES
|
|
235
|
+
from_process_env: bool = True
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def register_metadata_routes(app: FastAPI, source: MetadataSource) -> None:
|
|
239
|
+
"""Register the configmap, manifest and input-contract routes on *app*."""
|
|
240
|
+
|
|
241
|
+
def _default_entrypoint(log: Any, context: str) -> str | None:
|
|
242
|
+
"""The default entry point, or ``None`` (logged) when there is none."""
|
|
243
|
+
if source.default_entrypoint is None:
|
|
244
|
+
ep_name = tree_default_entrypoint(source.generated_dir())
|
|
245
|
+
if ep_name is None:
|
|
246
|
+
log.warning(context, source.app_name())
|
|
247
|
+
return ep_name
|
|
248
|
+
try:
|
|
249
|
+
return source.default_entrypoint()
|
|
250
|
+
except HTTPException:
|
|
251
|
+
# The registry says why there is no default. Warning, not debug:
|
|
252
|
+
# exc_info-worthy telemetry an operator should see in production.
|
|
253
|
+
log.warning(context, source.app_name(), exc_info=True)
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
def _explicit_entrypoints() -> list[str]:
|
|
257
|
+
if source.explicit_entrypoints is not None:
|
|
258
|
+
return source.explicit_entrypoints()
|
|
259
|
+
return tree_entrypoints(source.generated_dir())
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# ConfigMaps
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
@app.get("/workflows/v1/configmap/{config_map_id}")
|
|
266
|
+
async def get_configmap(config_map_id: str) -> JSONResponse:
|
|
267
|
+
log = source.log()
|
|
268
|
+
generated_dir = source.generated_dir()
|
|
269
|
+
# 0. The reserved build-identity id (FND-1684).
|
|
270
|
+
#
|
|
271
|
+
# Answered BEFORE the generated-file scan, deliberately: an app that
|
|
272
|
+
# happens to ship `atlan-build-identity.json` would otherwise shadow the
|
|
273
|
+
# one fact only a running process can report — and it would shadow it
|
|
274
|
+
# with a committed file, which cannot tell this build apart from one
|
|
275
|
+
# shipped months ago. Served on THIS route because
|
|
276
|
+
# `/api/service/configmaps/{name}` is already proxied by Heracles.
|
|
277
|
+
#
|
|
278
|
+
# `build_id` is "" for an image that carries no stamp. That is a valid
|
|
279
|
+
# answer, not an error: the reader has to distinguish "this process
|
|
280
|
+
# reports a different build" from "this process cannot report one".
|
|
281
|
+
if config_map_id == BUILD_IDENTITY_CONFIGMAP_ID:
|
|
282
|
+
identity: dict[str, Any] = {
|
|
283
|
+
"build_id": build_identity(),
|
|
284
|
+
"app_name": source.app_name(),
|
|
285
|
+
}
|
|
286
|
+
return JSONResponse(
|
|
287
|
+
content=_wrap_response(
|
|
288
|
+
cast(
|
|
289
|
+
"dict[str, Any]",
|
|
290
|
+
{
|
|
291
|
+
"kind": "ConfigMap",
|
|
292
|
+
"apiVersion": "v1",
|
|
293
|
+
"metadata": {"name": config_map_id},
|
|
294
|
+
# Same envelope as a real configmap — `data.config`
|
|
295
|
+
# is a JSON string — with the parsed keys repeated
|
|
296
|
+
# alongside it for a client that wants them.
|
|
297
|
+
"data": {
|
|
298
|
+
"config": orjson.dumps(identity).decode(),
|
|
299
|
+
**identity,
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
),
|
|
303
|
+
message="Build identity fetched successfully",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# 1. Direct match against any generated configmap file stem. The setup
|
|
308
|
+
# form normally requests the form file by its stem.
|
|
309
|
+
available_configmaps: list[str] = []
|
|
310
|
+
target: Path | None = None
|
|
311
|
+
if generated_dir.exists():
|
|
312
|
+
for json_file in generated_dir.rglob("*.json"):
|
|
313
|
+
available_configmaps.append(json_file.stem)
|
|
314
|
+
if json_file.stem == config_map_id:
|
|
315
|
+
target = json_file
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
# 2. Default-entrypoint fallback. The marketplace UI sometimes builds
|
|
319
|
+
# the configmap URL from the app/marketplace id (e.g.
|
|
320
|
+
# "atlan-snowflake") rather than the form stem. Resolve to the app's
|
|
321
|
+
# default entrypoint and serve its form. When there is none, or it
|
|
322
|
+
# has no eligible form, target stays None and the route 404s.
|
|
323
|
+
if target is None:
|
|
324
|
+
ep_name = _default_entrypoint(
|
|
325
|
+
log, "No default entrypoint for app %s; serving configmap 404"
|
|
326
|
+
)
|
|
327
|
+
if ep_name is not None:
|
|
328
|
+
# Searches the nested layout first, then the flat one, so an
|
|
329
|
+
# app-id request resolves for either shape. Within a directory
|
|
330
|
+
# `choose_form_configmap` picks a form named for the entrypoint,
|
|
331
|
+
# else the only eligible file, else the alphabetically first.
|
|
332
|
+
# That last step is a compatibility guess; the warning below is
|
|
333
|
+
# what makes a wrong guess visible before anyone opens the wizard.
|
|
334
|
+
target, candidates = entrypoint_form(generated_dir, ep_name)
|
|
335
|
+
if target is not None:
|
|
336
|
+
if len(candidates) > 1 and not names_entrypoint(
|
|
337
|
+
target.stem, ep_name
|
|
338
|
+
):
|
|
339
|
+
# conformance: ignore[L009] logs caller-invisible context (the rejected candidates) that no HTTP response carries.
|
|
340
|
+
log.warning(
|
|
341
|
+
"ConfigMap form chosen alphabetically for entrypoint "
|
|
342
|
+
"%s: %d generated files are eligible and none is "
|
|
343
|
+
"named for it, so %s.json was served as the setup "
|
|
344
|
+
"form (candidates=%s). If that is the wrong file, "
|
|
345
|
+
"name the form <entrypoint>.json or "
|
|
346
|
+
"<source>-<entrypoint>.json in the app's contract.",
|
|
347
|
+
ep_name,
|
|
348
|
+
len(candidates),
|
|
349
|
+
target.stem,
|
|
350
|
+
[c.stem for c in candidates],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
if target is not None:
|
|
354
|
+
with open(target, encoding="utf-8") as f:
|
|
355
|
+
raw = json.load(f)
|
|
356
|
+
data: dict[str, Any] = {
|
|
357
|
+
"config": orjson.dumps(raw.get("config", raw)).decode()
|
|
358
|
+
}
|
|
359
|
+
# Some configmaps (e.g., connector apps) carry a top-level
|
|
360
|
+
# `defaultConnectorType`; surface it alongside `config` when present.
|
|
361
|
+
default_connector_type = raw.get("defaultConnectorType")
|
|
362
|
+
if default_connector_type is not None:
|
|
363
|
+
data["defaultConnectorType"] = default_connector_type
|
|
364
|
+
configmap = {
|
|
365
|
+
"kind": "ConfigMap",
|
|
366
|
+
"apiVersion": "v1",
|
|
367
|
+
"metadata": {"name": config_map_id},
|
|
368
|
+
"data": data,
|
|
369
|
+
}
|
|
370
|
+
return JSONResponse(
|
|
371
|
+
content=_wrap_response(
|
|
372
|
+
cast("dict[str, Any]", configmap),
|
|
373
|
+
message="ConfigMap fetched successfully",
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# conformance: ignore[L009] logs caller-invisible context (the available configmaps) that the generic 404 HTTPException detail does not carry.
|
|
378
|
+
log.warning(
|
|
379
|
+
"ConfigMap not found: requested=%s available=%s",
|
|
380
|
+
config_map_id,
|
|
381
|
+
sorted(available_configmaps),
|
|
382
|
+
)
|
|
383
|
+
raise HTTPException(
|
|
384
|
+
status_code=404, detail=f"ConfigMap '{config_map_id}' not found"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
@app.get("/workflows/v1/configmaps")
|
|
388
|
+
async def list_configmaps() -> JSONResponse:
|
|
389
|
+
# Everything the sibling route serves by exact stem, minus the DAG
|
|
390
|
+
# manifest, which `/workflows/v1/manifest` owns. Deliberately NOT
|
|
391
|
+
# `is_form_configmap`: the credential templates it excludes are ones
|
|
392
|
+
# the UI genuinely fetches by name.
|
|
393
|
+
generated_dir = source.generated_dir()
|
|
394
|
+
seen: set[str] = set()
|
|
395
|
+
configmap_ids: list[str] = []
|
|
396
|
+
if generated_dir.exists():
|
|
397
|
+
for json_file in generated_dir.rglob("*.json"):
|
|
398
|
+
stem = json_file.stem
|
|
399
|
+
if stem == MANIFEST_STEM or stem in seen:
|
|
400
|
+
continue
|
|
401
|
+
seen.add(stem)
|
|
402
|
+
configmap_ids.append(stem)
|
|
403
|
+
return JSONResponse(
|
|
404
|
+
content=_wrap_response(
|
|
405
|
+
cast("dict[str, Any]", {"configmaps": configmap_ids}),
|
|
406
|
+
message="ConfigMaps listed successfully",
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# ------------------------------------------------------------------
|
|
411
|
+
# Manifest
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
def _resolve_manifest_placeholders(raw: bytes, origin: str) -> bytes:
|
|
415
|
+
"""Substitute the manifest's ``{app_name}`` / ``{deployment_name}`` tokens.
|
|
416
|
+
|
|
417
|
+
Delegates to :func:`resolve_manifest_tokens` with the queue the app's
|
|
418
|
+
worker polls, so the served ``task_queue`` is stamped from it rather
|
|
419
|
+
than re-derived and hoped to match (FND-195 / CONNECT-183). An
|
|
420
|
+
``{app_name}`` token means the committed manifest predates the
|
|
421
|
+
toolkit's bake — a WARNING even when this fills it. With nothing to fill
|
|
422
|
+
it from, the token is served as-is and logged at ERROR: a literal
|
|
423
|
+
``{app_name}`` in the DAG is greppable, whereas an invented name reads
|
|
424
|
+
as a legitimate queue and hangs the run silently.
|
|
425
|
+
"""
|
|
426
|
+
log = source.log()
|
|
427
|
+
resolution = resolve_manifest_tokens(
|
|
428
|
+
raw,
|
|
429
|
+
task_queue=source.task_queue() or None,
|
|
430
|
+
app_name=source.app_name() or source.fallback_app_name or None,
|
|
431
|
+
deployment_fallback=source.deployment_name() or "default",
|
|
432
|
+
from_process_env=source.from_process_env,
|
|
433
|
+
)
|
|
434
|
+
if resolution.unresolved_app_name:
|
|
435
|
+
# conformance: ignore[L009] not a request-boundary handler — the manifest is
|
|
436
|
+
# still served (the token is the diagnostic); this log is the operator's signal.
|
|
437
|
+
log.error(
|
|
438
|
+
"Manifest for %s carries an unresolved {app_name} token and no app "
|
|
439
|
+
"name is available (neither a registered app class nor "
|
|
440
|
+
"ATLAN_APPLICATION_NAME), so the SDK is serving the literal token "
|
|
441
|
+
"rather than inventing a name that would look legitimate and be "
|
|
442
|
+
"polled by nobody. Failure attribution for runs started from this "
|
|
443
|
+
"manifest will be blank, and any task_queue the SDK could not stamp "
|
|
444
|
+
"(stamped queue: %r) targets no worker. Set ATLAN_APPLICATION_NAME "
|
|
445
|
+
"on the deployment, or regenerate the contract so the toolkit bakes "
|
|
446
|
+
"the app name.",
|
|
447
|
+
origin,
|
|
448
|
+
resolution.task_queue,
|
|
449
|
+
)
|
|
450
|
+
elif resolution.had_app_name_token:
|
|
451
|
+
log.warning(
|
|
452
|
+
"Manifest for %s shipped an unbaked {app_name} token; filled with "
|
|
453
|
+
"%r for this response. The committed manifest is stale — bump the "
|
|
454
|
+
"contract toolkit and regenerate contracts, since any writer of "
|
|
455
|
+
"this DAG that does not go through this route gets no such fallback.",
|
|
456
|
+
origin,
|
|
457
|
+
resolution.app_name,
|
|
458
|
+
)
|
|
459
|
+
return resolution.raw
|
|
460
|
+
|
|
461
|
+
async def _serve_entrypoint_manifest(
|
|
462
|
+
entrypoint_name: str,
|
|
463
|
+
fe_inputs: str | dict[str, Any] | None,
|
|
464
|
+
) -> Response:
|
|
465
|
+
"""Read <entrypoint>/manifest.json, substitute placeholders, run the
|
|
466
|
+
optional ``compute_manifest`` hook, and return the Response.
|
|
467
|
+
|
|
468
|
+
``fe_inputs`` arrives either as the raw URL-encoded JSON string (GET,
|
|
469
|
+
capped) or an already-parsed dict (POST body, uncapped). It stays
|
|
470
|
+
undecoded until the hook needs it.
|
|
471
|
+
"""
|
|
472
|
+
log = source.log()
|
|
473
|
+
generated_dir = source.generated_dir()
|
|
474
|
+
# {entrypoint_name → manifest_path} from glob(), never from user input,
|
|
475
|
+
# so the path that reaches read_bytes() is not tainted by the HTTP param.
|
|
476
|
+
registry: dict[str, Path] = {
|
|
477
|
+
p.parent.name: p for p in generated_dir.glob("*/manifest.json")
|
|
478
|
+
}
|
|
479
|
+
ep_manifest = registry.get(entrypoint_name)
|
|
480
|
+
if ep_manifest is None:
|
|
481
|
+
# Single-entrypoint apps emit the manifest FLAT, with no
|
|
482
|
+
# per-entrypoint subdir. Heracles/AE always sends ?entrypoint= on
|
|
483
|
+
# submit, so without this fallback a flat app 404s and every run
|
|
484
|
+
# submit fails.
|
|
485
|
+
flat_manifest = generated_dir / "manifest.json"
|
|
486
|
+
if flat_manifest.is_file():
|
|
487
|
+
ep_manifest = flat_manifest
|
|
488
|
+
else:
|
|
489
|
+
raise HTTPException(
|
|
490
|
+
status_code=404,
|
|
491
|
+
detail=f"No manifest found for entrypoint {entrypoint_name!r}",
|
|
492
|
+
)
|
|
493
|
+
raw = _resolve_manifest_placeholders(
|
|
494
|
+
ep_manifest.read_bytes(), f"entrypoint {entrypoint_name!r}"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Dynamic-manifest hook: hand the static manifest and the decoded
|
|
498
|
+
# `fe_inputs` to the app's `compute_manifest`, and serve what it
|
|
499
|
+
# returns. Apps without one get the static manifest unchanged.
|
|
500
|
+
compute = source.compute_manifest(entrypoint_name)
|
|
501
|
+
if compute is not None:
|
|
502
|
+
# Decode lazily and only here: an app with no hook has always
|
|
503
|
+
# ignored fe_inputs, so decoding at the route would start 413-ing
|
|
504
|
+
# static apps that work today.
|
|
505
|
+
fe_inputs_decoded = (
|
|
506
|
+
fe_inputs
|
|
507
|
+
if isinstance(fe_inputs, dict)
|
|
508
|
+
else decode_fe_inputs(fe_inputs, source.max_fe_inputs_bytes())
|
|
509
|
+
)
|
|
510
|
+
manifest_dict = orjson.loads(raw)
|
|
511
|
+
try:
|
|
512
|
+
computed = await compute(manifest_dict, fe_inputs_decoded)
|
|
513
|
+
except HTTPException:
|
|
514
|
+
raise
|
|
515
|
+
except Exception:
|
|
516
|
+
# Don't leak the hook's internals (stack/SQL/paths) to the
|
|
517
|
+
# caller — mirror the other handler routes' generic 500.
|
|
518
|
+
# conformance: ignore[L009] boundary handler logs the real exception (exc_info) then raises a sanitized HTTPException `from None`; the log is the only server-side record of the actual failure.
|
|
519
|
+
log.error(
|
|
520
|
+
"compute_manifest hook failed for entrypoint %r",
|
|
521
|
+
entrypoint_name,
|
|
522
|
+
exc_info=True,
|
|
523
|
+
)
|
|
524
|
+
raise HTTPException(
|
|
525
|
+
status_code=500, detail="Internal server error"
|
|
526
|
+
) from None
|
|
527
|
+
if not isinstance(computed, dict):
|
|
528
|
+
# conformance: ignore[L009] logs caller-invisible context (the actual returned type) that the generic 500 HTTPException detail omits.
|
|
529
|
+
log.error(
|
|
530
|
+
"compute_manifest for entrypoint %r returned %s, expected dict",
|
|
531
|
+
entrypoint_name,
|
|
532
|
+
type(computed).__name__,
|
|
533
|
+
)
|
|
534
|
+
raise HTTPException(status_code=500, detail="Internal server error")
|
|
535
|
+
# Reconcile the hook's output too: it replaces the manifest
|
|
536
|
+
# wholesale, so a token it re-introduced would otherwise be served
|
|
537
|
+
# unreconciled. Idempotent, so a no-op unless the hook added one.
|
|
538
|
+
raw = _resolve_manifest_placeholders(
|
|
539
|
+
orjson.dumps(computed),
|
|
540
|
+
f"entrypoint {entrypoint_name!r} (compute_manifest output)",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
return Response(content=raw, media_type="application/json")
|
|
544
|
+
|
|
545
|
+
async def _serve_manifest(
|
|
546
|
+
entrypoint: str | None,
|
|
547
|
+
fe_inputs: str | dict[str, Any] | None,
|
|
548
|
+
) -> Response:
|
|
549
|
+
"""Resolve the entrypoint and serve its manifest (GET and POST alike)."""
|
|
550
|
+
log = source.log()
|
|
551
|
+
generated_dir = source.generated_dir()
|
|
552
|
+
if entrypoint:
|
|
553
|
+
# Reject obviously-malformed names before touching disk.
|
|
554
|
+
if not _ENTRYPOINT_NAME_RE.match(entrypoint):
|
|
555
|
+
raise HTTPException(status_code=400, detail="Invalid entrypoint name")
|
|
556
|
+
return await _serve_entrypoint_manifest(entrypoint, fe_inputs)
|
|
557
|
+
|
|
558
|
+
# No entrypoint param: single-entrypoint path.
|
|
559
|
+
if source.programmatic_manifest is not None:
|
|
560
|
+
# A programmatic AppManifest has no contract-toolkit bake behind it,
|
|
561
|
+
# so it gets the same reconciliation as the files.
|
|
562
|
+
raw = _resolve_manifest_placeholders(
|
|
563
|
+
source.programmatic_manifest.model_dump_json().encode(),
|
|
564
|
+
"programmatic manifest",
|
|
565
|
+
)
|
|
566
|
+
return Response(content=raw, media_type="application/json")
|
|
567
|
+
manifest_path = generated_dir / "manifest.json"
|
|
568
|
+
if manifest_path.exists():
|
|
569
|
+
raw = _resolve_manifest_placeholders(
|
|
570
|
+
manifest_path.read_bytes(), "root manifest"
|
|
571
|
+
)
|
|
572
|
+
return Response(content=raw, media_type="application/json")
|
|
573
|
+
|
|
574
|
+
# Default-entrypoint fallback: multi-entrypoint apps have no root
|
|
575
|
+
# manifest by design. Try the default first, then every explicit
|
|
576
|
+
# entrypoint alphabetically — the same tie-break the framework uses
|
|
577
|
+
# when several @entrypoints carry no marked default.
|
|
578
|
+
default_ep = _default_entrypoint(
|
|
579
|
+
log,
|
|
580
|
+
"No default entrypoint for app %s; trying explicit-entrypoint "
|
|
581
|
+
"fallback before serving 404",
|
|
582
|
+
)
|
|
583
|
+
candidates: list[str] = []
|
|
584
|
+
if default_ep:
|
|
585
|
+
candidates.append(default_ep)
|
|
586
|
+
candidates.extend(e for e in _explicit_entrypoints() if e not in candidates)
|
|
587
|
+
for cand in candidates:
|
|
588
|
+
try:
|
|
589
|
+
return await _serve_entrypoint_manifest(cand, fe_inputs)
|
|
590
|
+
except HTTPException as exc:
|
|
591
|
+
if exc.status_code != 404:
|
|
592
|
+
raise
|
|
593
|
+
# This candidate has no manifest file on disk — try the next.
|
|
594
|
+
|
|
595
|
+
raise HTTPException(status_code=404, detail="No manifest available")
|
|
596
|
+
|
|
597
|
+
@app.get("/workflows/v1/manifest")
|
|
598
|
+
async def get_manifest(
|
|
599
|
+
entrypoint: str | None = None,
|
|
600
|
+
fe_inputs: str | None = None,
|
|
601
|
+
) -> Response:
|
|
602
|
+
"""Serve a manifest with ``fe_inputs`` as a query parameter.
|
|
603
|
+
|
|
604
|
+
Retained for the playground, connector integration tests and the
|
|
605
|
+
AE/marketplace probes, which send no ``fe_inputs``. Callers that do
|
|
606
|
+
should use ``POST``: the query string caps the payload (CSA-539).
|
|
607
|
+
"""
|
|
608
|
+
return await _serve_manifest(entrypoint, fe_inputs)
|
|
609
|
+
|
|
610
|
+
@app.post("/workflows/v1/manifest")
|
|
611
|
+
async def post_manifest(request: Request) -> Response:
|
|
612
|
+
"""Serve a manifest with ``fe_inputs`` in the request body.
|
|
613
|
+
|
|
614
|
+
Body: ``{"entrypoint": str | null, "fe_inputs": {...}, "user_id": str | null}``
|
|
615
|
+
— all optional. ``{}``, ``null`` and an absent body equal a bare GET.
|
|
616
|
+
``fe_inputs`` must be a JSON object. ``user_id`` is accepted and ignored.
|
|
617
|
+
"""
|
|
618
|
+
raw = await request.body()
|
|
619
|
+
if not raw.strip():
|
|
620
|
+
# Absent/empty body means "no inputs". Callers fall back only on
|
|
621
|
+
# 405, so 400-ing this would hard-fail them.
|
|
622
|
+
body: Any = {}
|
|
623
|
+
else:
|
|
624
|
+
try:
|
|
625
|
+
body = await request.json()
|
|
626
|
+
except ValueError as exc:
|
|
627
|
+
# A fixed message: no parser or exception text in a response
|
|
628
|
+
# body. ValueError covers JSONDecodeError and a non-UTF-8 body.
|
|
629
|
+
raise HTTPException(
|
|
630
|
+
status_code=400, detail="Request body is not valid JSON"
|
|
631
|
+
) from exc
|
|
632
|
+
if body is None:
|
|
633
|
+
body = {}
|
|
634
|
+
if not isinstance(body, dict):
|
|
635
|
+
raise HTTPException(
|
|
636
|
+
status_code=400, detail="Request body must be a JSON object"
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
entrypoint = body.get("entrypoint")
|
|
640
|
+
if entrypoint is not None and not isinstance(entrypoint, str):
|
|
641
|
+
raise HTTPException(status_code=400, detail="entrypoint must be a string")
|
|
642
|
+
|
|
643
|
+
fe_inputs = body.get("fe_inputs")
|
|
644
|
+
if fe_inputs is None:
|
|
645
|
+
fe_inputs = {}
|
|
646
|
+
if not isinstance(fe_inputs, dict):
|
|
647
|
+
raise HTTPException(
|
|
648
|
+
status_code=400, detail="fe_inputs must be a JSON object"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
return await _serve_manifest(entrypoint, fe_inputs)
|
|
652
|
+
|
|
653
|
+
# Backwards-compatibility alias — TACTICAL. Heracles / Automation Engine
|
|
654
|
+
# still call the v2-era unversioned /manifest (BLDX-804).
|
|
655
|
+
|
|
656
|
+
@app.get("/manifest", include_in_schema=False)
|
|
657
|
+
async def get_manifest_legacy(
|
|
658
|
+
entrypoint: str | None = None,
|
|
659
|
+
fe_inputs: str | None = None,
|
|
660
|
+
) -> Response:
|
|
661
|
+
"""Unversioned alias for ``GET /workflows/v1/manifest``. Do not add callers."""
|
|
662
|
+
return await get_manifest(entrypoint=entrypoint, fe_inputs=fe_inputs)
|
|
663
|
+
|
|
664
|
+
@app.post("/manifest", include_in_schema=False)
|
|
665
|
+
async def post_manifest_legacy(request: Request) -> Response:
|
|
666
|
+
"""Unversioned alias for ``POST /workflows/v1/manifest``. Do not add callers."""
|
|
667
|
+
return await post_manifest(request)
|
|
668
|
+
|
|
669
|
+
# ------------------------------------------------------------------
|
|
670
|
+
# Input contract
|
|
671
|
+
# ------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
@app.get("/workflows/v1/input-contract")
|
|
674
|
+
async def get_input_contract(entrypoint: str | None = None) -> Response:
|
|
675
|
+
"""Return the input contract (JSON Schema) for an entry point.
|
|
676
|
+
|
|
677
|
+
The app's machine-readable input contract: Heracles uses it to validate
|
|
678
|
+
caller inputs and to find credential fields (a ``$ref`` to the
|
|
679
|
+
``CredentialRef`` / ``AgentCredentialSpec`` def). An explicit
|
|
680
|
+
``?entrypoint=`` selects it; a single-entry-point app resolves
|
|
681
|
+
automatically; a multi-entry-point app requires the param.
|
|
682
|
+
"""
|
|
683
|
+
if entrypoint is not None and not _ENTRYPOINT_NAME_RE.match(entrypoint):
|
|
684
|
+
raise HTTPException(status_code=400, detail="Invalid entrypoint name")
|
|
685
|
+
if source.input_contract is not None:
|
|
686
|
+
contract = source.input_contract(entrypoint)
|
|
687
|
+
else:
|
|
688
|
+
contract = tree_input_contract(
|
|
689
|
+
source.generated_dir(), source.app_package, entrypoint
|
|
690
|
+
)
|
|
691
|
+
return Response(
|
|
692
|
+
content=orjson.dumps(contract.model_json_schema()),
|
|
693
|
+
media_type="application/json",
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def generated_dir_of(package_dir: str | os.PathLike[str]) -> Path:
|
|
698
|
+
"""Where an app package built by ``gen_app_api.py`` ships its generated files."""
|
|
699
|
+
return Path(package_dir) / "generated"
|