Skip to content

Commit 00bc962

Browse files
feat: new tos connector args (#4129)
* feat: new tos connector args * fix: skill ut * fix: param check * fix: param check * fix: param check * fix: clarify connector watch eventual consistency * fix: allow plaintext connector watches and tighten TOS URI validation
1 parent daf5fb1 commit 00bc962

9 files changed

Lines changed: 1043 additions & 101 deletions

File tree

openviking/connector/delegate.py

Lines changed: 219 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@
1717
from __future__ import annotations
1818

1919
import asyncio
20+
import base64
21+
import json
22+
import re
2023
import time
2124
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple
25+
from urllib.parse import urlsplit
2226

2327
import httpx
2428

@@ -31,6 +35,7 @@
3135
detect_connector_add_type,
3236
is_full_commit_sha,
3337
)
38+
from openviking.crypto.encryptor import MAGIC as ENCRYPTED_ENVELOPE_MAGIC
3439
from openviking.parse.mode import ParseMode
3540
from openviking.resource.processing_mode import (
3641
DEFAULT_PROCESSING_MODE,
@@ -44,6 +49,50 @@
4449

4550
logger = get_logger(__name__)
4651

52+
_TOS_BUCKET_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$")
53+
54+
55+
def _validate_tos_uri(
56+
value: Any,
57+
field: str,
58+
*,
59+
allow_bucket_without_slash: bool = False,
60+
) -> None:
61+
"""Validate a Connector TOS URI without exposing it in client errors."""
62+
error = InvalidArgumentError(f"{field} must be a valid TOS URI.")
63+
if (
64+
not isinstance(value, str)
65+
or value != value.strip()
66+
or not value.startswith("tos://")
67+
or any(
68+
char in "?#%" or ord(char) < 0x20 or ord(char) == 0x7F
69+
for char in value
70+
)
71+
):
72+
raise error
73+
74+
try:
75+
parsed = urlsplit(value)
76+
hostname = parsed.hostname
77+
port = parsed.port
78+
except ValueError:
79+
raise error from None
80+
if (
81+
parsed.scheme != "tos"
82+
or parsed.username is not None
83+
or parsed.password is not None
84+
or port is not None
85+
or not hostname
86+
or hostname != parsed.netloc
87+
or not _TOS_BUCKET_PATTERN.fullmatch(hostname)
88+
or (not parsed.path and not allow_bucket_without_slash)
89+
or (parsed.path and not parsed.path.startswith("/"))
90+
or parsed.path.startswith("//")
91+
or parsed.query
92+
or parsed.fragment
93+
):
94+
raise error
95+
4796

4897
class ConnectorDelegate:
4998
"""Routes add_resource requests to the external Connector service.
@@ -71,6 +120,113 @@ def __init__(
71120
self._background_tasks = background_tasks
72121
self._link_reason_memory = link_reason_memory
73122

123+
_WATCH_AUTH_PROVIDER = "connector_encrypted"
124+
_WATCH_PLAINTEXT_AUTH_PROVIDER = "connector_plaintext"
125+
126+
def _watch_encryptor(self) -> Any:
127+
encryptor = getattr(self._viking_fs, "_encryptor", None)
128+
if encryptor is None:
129+
raise InvalidArgumentError(
130+
"Connector watch requires encryption.enabled=true so credentials are "
131+
"encrypted at rest."
132+
)
133+
return encryptor
134+
135+
async def create_watch_auth_state(
136+
self,
137+
*,
138+
api_key: str,
139+
account_id: str,
140+
add_type: str,
141+
path: str,
142+
connector_args: Optional[Dict[str, Any]],
143+
) -> Dict[str, Any]:
144+
"""Build the private request state needed to replay a Connector watch."""
145+
if not api_key:
146+
raise InvalidArgumentError("Connector watch requires an API key.")
147+
payload = {
148+
"api_key": api_key,
149+
"account_id": account_id,
150+
"add_type": add_type,
151+
"path": path,
152+
"connector_args": dict(connector_args or {}),
153+
}
154+
encryptor = getattr(self._viking_fs, "_encryptor", None)
155+
if encryptor is None:
156+
return {
157+
"provider": self._WATCH_PLAINTEXT_AUTH_PROVIDER,
158+
"request": payload,
159+
}
160+
try:
161+
plaintext = json.dumps(
162+
payload,
163+
ensure_ascii=False,
164+
separators=(",", ":"),
165+
).encode("utf-8")
166+
ciphertext = await encryptor.encrypt(account_id, plaintext)
167+
except InvalidArgumentError:
168+
raise
169+
except Exception as exc:
170+
raise InvalidArgumentError("Failed to encrypt Connector watch credentials.") from exc
171+
return {
172+
"provider": self._WATCH_AUTH_PROVIDER,
173+
"ciphertext": base64.b64encode(ciphertext).decode("ascii"),
174+
}
175+
176+
@classmethod
177+
def is_watch_auth_state(cls, auth_state: Optional[Dict[str, Any]]) -> bool:
178+
return (
179+
isinstance(auth_state, dict)
180+
and auth_state.get("provider")
181+
in {cls._WATCH_AUTH_PROVIDER, cls._WATCH_PLAINTEXT_AUTH_PROVIDER}
182+
)
183+
184+
async def restore_watch_request(
185+
self,
186+
auth_state: Dict[str, Any],
187+
*,
188+
account_id: str,
189+
path: str,
190+
) -> Tuple[str, str, Dict[str, Any]]:
191+
"""Restore and validate a source-bound Connector watch request."""
192+
try:
193+
provider = auth_state.get("provider")
194+
if provider == self._WATCH_PLAINTEXT_AUTH_PROVIDER:
195+
payload = auth_state.get("request")
196+
elif provider == self._WATCH_AUTH_PROVIDER:
197+
encoded = auth_state.get("ciphertext")
198+
if not isinstance(encoded, str) or not encoded:
199+
raise ValueError("missing ciphertext")
200+
ciphertext = base64.b64decode(encoded, validate=True)
201+
if not ciphertext.startswith(ENCRYPTED_ENVELOPE_MAGIC):
202+
raise ValueError("invalid encrypted envelope")
203+
plaintext = await self._watch_encryptor().decrypt(account_id, ciphertext)
204+
payload = json.loads(plaintext.decode("utf-8"))
205+
else:
206+
raise ValueError("unknown Connector watch provider")
207+
if (
208+
not isinstance(payload, dict)
209+
or payload.get("account_id") != account_id
210+
or payload.get("path") != path
211+
):
212+
raise ValueError("watch binding mismatch")
213+
api_key = payload.get("api_key")
214+
add_type = payload.get("add_type")
215+
connector_args = payload.get("connector_args")
216+
if (
217+
not isinstance(api_key, str)
218+
or not api_key
219+
or not isinstance(add_type, str)
220+
or not add_type
221+
or not isinstance(connector_args, dict)
222+
):
223+
raise ValueError("invalid watch request")
224+
return api_key, add_type, dict(connector_args)
225+
except InvalidArgumentError:
226+
raise
227+
except Exception as exc:
228+
raise InvalidArgumentError("Stored Connector watch credentials are invalid.") from exc
229+
74230
@staticmethod
75231
def resolve_add_type(path: str, declared_add_type: Optional[str]) -> Optional[Tuple[str, bool]]:
76232
"""Resolve the Connector ``(add_type, connector_only)`` for *path*.
@@ -100,6 +256,14 @@ def resolve_add_type(path: str, declared_add_type: Optional[str]) -> Optional[Tu
100256
)
101257
return (declared_add_type, True)
102258

259+
@classmethod
260+
def supported_args(cls, path: str, declared_add_type: Optional[str]) -> Set[str]:
261+
"""Connector-owned ``args`` fields for the resolved source type."""
262+
resolved = cls.resolve_add_type(path, declared_add_type)
263+
if resolved is None:
264+
return set()
265+
return set(CONNECTOR_SUPPORTED_ARGS.get(resolved[0], frozenset()))
266+
103267
def should_delegate(
104268
self,
105269
path: str,
@@ -211,7 +375,7 @@ def should_delegate(
211375
f"standard import pipeline. Connector import does not support: {detail}"
212376
)
213377
logger.info(
214-
f"[ConnectorDelegate] Connector does not support {detail} for path {path}; "
378+
f"[ConnectorDelegate] Connector does not support {detail}; "
215379
"falling back to the standard import pipeline"
216380
)
217381
return False
@@ -247,8 +411,6 @@ def _unsupported_params(
247411
unsupported.append("missing exact 'to' target")
248412
elif to != "viking://resources" and not to.startswith("viking://resources/"):
249413
unsupported.append("to outside the public resources root (viking://resources/...)")
250-
if watch_interval > 0:
251-
unsupported.append("watch_interval>0 (Connector imports cannot be watched yet)")
252414
if instruction:
253415
unsupported.append("instruction")
254416
if not build_index:
@@ -260,6 +422,8 @@ def _unsupported_params(
260422
if kwargs.get("strict"):
261423
unsupported.append("strict=true (Connector imports fail per file, not all-or-nothing)")
262424
for field in ("ignore_dirs", "include", "exclude"):
425+
if field == "exclude" and add_type == "tos" and field in connector_args:
426+
continue
263427
if kwargs.get(field):
264428
unsupported.append(f"{field} (Connector imports cannot filter the source tree)")
265429
if kwargs.get("preserve_structure") is False:
@@ -301,6 +465,8 @@ async def submit(
301465
connector_args: Optional[Dict[str, Any]] = None,
302466
tags: Optional[List[str]] = None,
303467
tag_mode: str = "replace",
468+
wait_for_completion: bool = False,
469+
on_success: Optional[Callable[[], Awaitable[None]]] = None,
304470
**kwargs: Any,
305471
) -> Dict[str, Any]:
306472
"""Route add_resource to the external Connector service."""
@@ -315,6 +481,8 @@ async def submit(
315481
if resolved is None:
316482
raise InvalidArgumentError(f"'{path}' does not match any Connector source type.")
317483
add_type, _ = resolved
484+
if add_type == "tos":
485+
_validate_tos_uri(path, "path", allow_bucket_without_slash=True)
318486

319487
task_resource_id = to or ""
320488
if not task_resource_id:
@@ -369,12 +537,34 @@ async def submit(
369537
tos_path: Optional[str] = None
370538
param_config: Optional[Dict[str, Any]] = None
371539
if add_type == "tos":
372-
source_path = path[len("tos://") :].strip()
373-
if not source_path:
374-
raise InvalidArgumentError(
375-
"Connector TOS import requires path='tos://<bucket>/<path>'."
376-
)
377-
tos_path = source_path
540+
tos_args = connector_args or {}
541+
if "tos_prefix" not in tos_args:
542+
if "exclude" in tos_args:
543+
raise InvalidArgumentError("args.exclude requires args.tos_prefix.")
544+
source_path = path[len("tos://") :].strip()
545+
if not source_path:
546+
raise InvalidArgumentError(
547+
"Connector TOS import requires path='tos://<bucket>/<path>'."
548+
)
549+
tos_path = source_path
550+
else:
551+
tos_prefix = tos_args["tos_prefix"]
552+
if not isinstance(tos_prefix, list) or not tos_prefix:
553+
raise InvalidArgumentError(
554+
"args.tos_prefix must be a non-empty list of TOS URIs."
555+
)
556+
for index, source in enumerate(tos_prefix):
557+
_validate_tos_uri(source, f"args.tos_prefix[{index}]")
558+
if tos_prefix[0] != path:
559+
raise InvalidArgumentError("path must equal the first item in args.tos_prefix.")
560+
exclude = tos_args.get("exclude", [])
561+
if not isinstance(exclude, list):
562+
raise InvalidArgumentError("args.exclude must be a list of TOS URIs.")
563+
for index, source in enumerate(exclude):
564+
_validate_tos_uri(source, f"args.exclude[{index}]")
565+
param_config = {"tos_prefix": tos_prefix}
566+
if exclude:
567+
param_config["exclude"] = exclude
378568
elif add_type == "git":
379569
from openviking.parse.accessors.git_accessor import GitAccessor
380570

@@ -462,19 +652,23 @@ async def submit(
462652
ctx=ctx,
463653
reason=reason,
464654
link_root_uri=task_resource_id or "viking://resources",
655+
on_success=on_success,
465656
)
466657

467-
background = asyncio.create_task(monitor)
468-
self._background_tasks.add(background)
469-
background.add_done_callback(self._background_tasks.discard)
470-
471658
response = {
472659
"status": "accepted",
473660
"task_id": task.task_id,
474661
"connector_task_key": connector_task_key,
475662
}
476663
if task_resource_id:
477664
response["resource_id"] = task_resource_id
665+
if wait_for_completion:
666+
response.update(await monitor)
667+
return response
668+
669+
background = asyncio.create_task(monitor)
670+
self._background_tasks.add(background)
671+
background.add_done_callback(self._background_tasks.discard)
478672
return response
479673

480674
async def _monitor(
@@ -487,6 +681,7 @@ async def _monitor(
487681
ctx: RequestContext,
488682
reason: str = "",
489683
link_root_uri: str = "",
684+
on_success: Optional[Callable[[], Awaitable[None]]] = None,
490685
) -> Dict[str, Any]:
491686
"""Poll the Connector task until terminal state, then update OV TaskRecord.
492687
@@ -523,10 +718,10 @@ async def _monitor(
523718
f"for {connector_task_key}: {status_code}; retrying"
524719
)
525720
continue
526-
except httpx.RequestError as exc:
721+
except httpx.RequestError:
527722
logger.warning(
528723
"[ConnectorDelegate] Transient Connector task polling error "
529-
f"for {connector_task_key}: {exc}; retrying"
724+
f"for {connector_task_key}; retrying"
530725
)
531726
continue
532727
status = (info.get("Status") or info.get("status") or "").lower()
@@ -544,6 +739,8 @@ async def _monitor(
544739
"connector_status": status,
545740
"connector_task_key": connector_task_key,
546741
}
742+
if on_success is not None:
743+
await on_success()
547744
if (reason or "").strip() and link_root_uri:
548745
link_result: Dict[str, Any] = {"root_uri": link_root_uri}
549746
await self._link_reason_memory(
@@ -590,11 +787,15 @@ async def _monitor(
590787
)
591788
raise
592789
except Exception as exc:
593-
logger.error(f"[ConnectorDelegate] Connector task monitor error: {exc}")
790+
failure = "connector task monitoring failed"
791+
logger.error(
792+
"[ConnectorDelegate] Connector task monitor error, error_type=%s",
793+
type(exc).__name__,
794+
)
594795
await task_tracker.fail(
595796
ov_task_id,
596-
str(exc),
797+
failure,
597798
account_id=ctx.account_id,
598799
user_id=ctx.user.user_id,
599800
)
600-
return {"status": "failed", "error": str(exc)}
801+
return {"status": "failed", "error": failure}

openviking/connector/routing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
# the set flow into the unsupported-parameter framework: connector-only
2424
# sources reject them, shared sources degrade to the standard pipeline.
2525
CONNECTOR_SUPPORTED_ARGS: Dict[str, FrozenSet[str]] = {
26-
"tos": frozenset(),
26+
"tos": frozenset({"tos_prefix", "exclude"}),
2727
"git": frozenset({"branch", "ref", "commit"}),
2828
}
2929

0 commit comments

Comments
 (0)