agentenv-framework 0.9.1254__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.
- agent_env/__init__.py +0 -0
- agent_env/a2a_agent/__init__.py +23 -0
- agent_env/a2a_agent/a2a_agent.py +600 -0
- agent_env/a2a_agent/conversation_store.py +356 -0
- agent_env/a2a_agent/object_transfer.py +609 -0
- agent_env/a2a_agent/protocol.py +238 -0
- agent_env/a2a_agent/store.py +201 -0
- agent_env/a2a_agent/validator.py +901 -0
- agent_env/artifact/__init__.py +48 -0
- agent_env/artifact/artifact.py +74 -0
- agent_env/artifact/artifacts/__init__.py +0 -0
- agent_env/artifact/artifacts/cli.py +83 -0
- agent_env/artifact/artifacts/docker_image.py +457 -0
- agent_env/artifact/artifacts/environment.py +89 -0
- agent_env/artifact/artifacts/environment_universe.py +156 -0
- agent_env/artifact/artifacts/file.py +214 -0
- agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
- agent_env/artifact/artifacts/skill.py +206 -0
- agent_env/artifact/artifacts/vm_image.py +95 -0
- agent_env/artifact/ref.py +15 -0
- agent_env/artifact/registry.py +251 -0
- agent_env/artifact/store.py +295 -0
- agent_env/artifact/universe.py +38 -0
- agent_env/attribution.py +41 -0
- agent_env/bundle/__init__.py +4 -0
- agent_env/bundle/_fs.py +128 -0
- agent_env/bundle/authoring.py +239 -0
- agent_env/bundle/installed.py +175 -0
- agent_env/bundle/ledger.py +269 -0
- agent_env/bundle/materialize.py +204 -0
- agent_env/bundle/parse.py +594 -0
- agent_env/bundle/plan.py +414 -0
- agent_env/bundle/resolve.py +404 -0
- agent_env/bundle/run.py +361 -0
- agent_env/cli/__init__.py +69 -0
- agent_env/cli/__main__.py +10 -0
- agent_env/cli/_installers.py +576 -0
- agent_env/cli/_plugin_changes.py +947 -0
- agent_env/cli/a2a_agent/__init__.py +22 -0
- agent_env/cli/a2a_agent/add_skill.py +97 -0
- agent_env/cli/a2a_agent/deploy.py +60 -0
- agent_env/cli/a2a_agent/get.py +15 -0
- agent_env/cli/a2a_agent/get_instance.py +26 -0
- agent_env/cli/a2a_agent/put.py +78 -0
- agent_env/cli/a2a_agent/validate.py +56 -0
- agent_env/cli/artifact/__init__.py +20 -0
- agent_env/cli/artifact/cli.py +83 -0
- agent_env/cli/artifact/environment.py +44 -0
- agent_env/cli/artifact/environment_universe.py +163 -0
- agent_env/cli/artifact/file_artifact_universe.py +281 -0
- agent_env/cli/artifact/skill.py +128 -0
- agent_env/cli/banner.py +25 -0
- agent_env/cli/config.py +263 -0
- agent_env/cli/env/__init__.py +31 -0
- agent_env/cli/env/deploy.py +112 -0
- agent_env/cli/env/gateway.py +57 -0
- agent_env/cli/env/get_instance.py +40 -0
- agent_env/cli/env/mcp_server.py +410 -0
- agent_env/cli/env/multi.py +282 -0
- agent_env/cli/env/service_db.py +99 -0
- agent_env/cli/env/snapshot.py +35 -0
- agent_env/cli/env/state/__init__.py +6 -0
- agent_env/cli/env/state/init.py +108 -0
- agent_env/cli/env/state/teardown.py +44 -0
- agent_env/cli/env/website.py +216 -0
- agent_env/cli/env/website_browser.py +67 -0
- agent_env/cli/eval/__init__.py +16 -0
- agent_env/cli/eval/add_tasks.py +63 -0
- agent_env/cli/eval/create.py +49 -0
- agent_env/cli/eval/run.py +194 -0
- agent_env/cli/identity/__init__.py +5 -0
- agent_env/cli/identity/client_identity.py +37 -0
- agent_env/cli/plugin.py +438 -0
- agent_env/cli/run.py +285 -0
- agent_env/cli/task/__init__.py +21 -0
- agent_env/cli/task/create.py +109 -0
- agent_env/cli/task/get.py +17 -0
- agent_env/cli/task/get_instance.py +34 -0
- agent_env/cli/task/run.py +615 -0
- agent_env/cli/task/validate.py +31 -0
- agent_env/cli/up.py +123 -0
- agent_env/cli/utils.py +183 -0
- agent_env/config/__init__.py +50 -0
- agent_env/config/describe.py +1067 -0
- agent_env/config/errors.py +13 -0
- agent_env/config/loader.py +245 -0
- agent_env/config/model.py +154 -0
- agent_env/config/paths.py +25 -0
- agent_env/config/plugin_tables.py +101 -0
- agent_env/config/provenance.py +134 -0
- agent_env/config/runtime.py +1064 -0
- agent_env/config/snapshot.py +81 -0
- agent_env/entity_refs.py +186 -0
- agent_env/env/__init__.py +49 -0
- agent_env/env/env.py +433 -0
- agent_env/env/env_artifact_store.py +87 -0
- agent_env/env/envs/__init__.py +7 -0
- agent_env/env/envs/_deployment.py +281 -0
- agent_env/env/envs/gateway_server.py +32 -0
- agent_env/env/envs/mcp_server.py +387 -0
- agent_env/env/envs/multi_env.py +780 -0
- agent_env/env/envs/service_db/Dockerfile +20 -0
- agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
- agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
- agent_env/env/envs/service_db.py +92 -0
- agent_env/env/envs/website.py +294 -0
- agent_env/env/envs/website_browser/Dockerfile +40 -0
- agent_env/env/envs/website_browser/__init__.py +17 -0
- agent_env/env/envs/website_browser/entrypoint.sh +13 -0
- agent_env/env/gateway/Dockerfile +14 -0
- agent_env/env/gateway/__init__.py +37 -0
- agent_env/env/gateway/clock.py +184 -0
- agent_env/env/gateway/constants.py +190 -0
- agent_env/env/gateway/entrypoint.py +156 -0
- agent_env/env/gateway/gateway.py +1095 -0
- agent_env/env/gateway/get_time.py +60 -0
- agent_env/env/gateway/requirements.txt +4 -0
- agent_env/env/gateway/triggers.py +1193 -0
- agent_env/env/legacy_protocol.py +146 -0
- agent_env/env/registry.py +93 -0
- agent_env/env/snapshot_store.py +372 -0
- agent_env/env/store.py +411 -0
- agent_env/eval/__init__.py +14 -0
- agent_env/eval/eval.py +68 -0
- agent_env/eval/store.py +116 -0
- agent_env/examples/hello/README.md +9 -0
- agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
- agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
- agent_env/examples/hello/tasks/hello.json +10 -0
- agent_env/explorer/__init__.py +12 -0
- agent_env/explorer/app.py +277 -0
- agent_env/explorer/openapi_docs.py +242 -0
- agent_env/explorer/plugin.py +95 -0
- agent_env/explorer/routers/__init__.py +1 -0
- agent_env/explorer/routers/common.py +174 -0
- agent_env/explorer/routers/conversations.py +25 -0
- agent_env/explorer/routers/objects.py +112 -0
- agent_env/explorer/routers/runs.py +475 -0
- agent_env/plugins/__init__.py +59 -0
- agent_env/plugins/_cli.py +244 -0
- agent_env/plugins/_discovery.py +97 -0
- agent_env/plugins/_inventory.py +262 -0
- agent_env/plugins/_registration.py +268 -0
- agent_env/plugins/_report.py +47 -0
- agent_env/plugins/_requirements.py +55 -0
- agent_env/providers/__init__.py +37 -0
- agent_env/providers/env_providers/__init__.py +12 -0
- agent_env/providers/env_providers/constants.py +13 -0
- agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
- agent_env/providers/env_providers/env_provider.py +237 -0
- agent_env/providers/env_providers/env_server_provider.py +133 -0
- agent_env/providers/env_state/__init__.py +49 -0
- agent_env/providers/env_state/env_state_provider.py +554 -0
- agent_env/providers/env_state/local_postgres.py +390 -0
- agent_env/providers/env_state/store.py +112 -0
- agent_env/providers/sandbox_providers/__init__.py +29 -0
- agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
- agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
- agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
- agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
- agent_env/providers/sandbox_providers/e2b/template.py +158 -0
- agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
- agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
- agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
- agent_env/providers/sandbox_providers/sandbox.py +387 -0
- agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
- agent_env/py.typed +0 -0
- agent_env/runner/__init__.py +16 -0
- agent_env/runner/local_runner.py +190 -0
- agent_env/runner/runner.py +127 -0
- agent_env/runner/store.py +94 -0
- agent_env/store/__init__.py +126 -0
- agent_env/store/_google.py +68 -0
- agent_env/store/base.py +33 -0
- agent_env/store/document_store/__init__.py +60 -0
- agent_env/store/document_store/document_store.py +519 -0
- agent_env/store/document_store/dynamodb_document_store.py +306 -0
- agent_env/store/document_store/evaluation.py +199 -0
- agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
- agent_env/store/document_store/mongo_document_store.py +259 -0
- agent_env/store/document_store/sqlite_document_store.py +314 -0
- agent_env/store/ids.py +123 -0
- agent_env/store/image_store/__init__.py +34 -0
- agent_env/store/image_store/ecr_image_store.py +131 -0
- agent_env/store/image_store/google_credentials.py +113 -0
- agent_env/store/image_store/image_store.py +113 -0
- agent_env/store/image_store/local_registry_image_store.py +106 -0
- agent_env/store/image_store/oci_registry_credentials.py +196 -0
- agent_env/store/local_state.py +27 -0
- agent_env/store/object_store/__init__.py +24 -0
- agent_env/store/object_store/gcs_object_store.py +415 -0
- agent_env/store/object_store/local_object_store.py +143 -0
- agent_env/store/object_store/object_store.py +198 -0
- agent_env/store/object_store/s3_object_store.py +354 -0
- agent_env/store/query.py +112 -0
- agent_env/store/routing.py +609 -0
- agent_env/store/secret_store/__init__.py +19 -0
- agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
- agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
- agent_env/store/secret_store/local_secret_store.py +47 -0
- agent_env/store/secret_store/secret_store.py +19 -0
- agent_env/task/__init__.py +27 -0
- agent_env/task/interrupts.py +109 -0
- agent_env/task/registry.py +27 -0
- agent_env/task/step_journal.py +99 -0
- agent_env/task/store.py +940 -0
- agent_env/task/task.py +815 -0
- agent_env/task/teardown.py +142 -0
- agent_env/task_step/__init__.py +47 -0
- agent_env/task_step/context.py +195 -0
- agent_env/task_step/context_ops.py +224 -0
- agent_env/task_step/registry.py +182 -0
- agent_env/task_step/review_store.py +118 -0
- agent_env/task_step/snapshot_utils/__init__.py +2 -0
- agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
- agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
- agent_env/task_step/store.py +129 -0
- agent_env/task_step/task_step.py +173 -0
- agent_env/task_step/task_steps/__init__.py +27 -0
- agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
- agent_env/task_step/task_steps/add_skills.py +240 -0
- agent_env/task_step/task_steps/apply_server_config.py +288 -0
- agent_env/task_step/task_steps/collect_artifacts.py +940 -0
- agent_env/task_step/task_steps/deploy_agent.py +626 -0
- agent_env/task_step/task_steps/deploy_env.py +197 -0
- agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
- agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
- agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
- agent_env/task_step/task_steps/install_agent.py +376 -0
- agent_env/task_step/task_steps/load_artifact.py +775 -0
- agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
- agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
- agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
- agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
- agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
- agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
- agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
- agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
- agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
- agent_env/task_step/task_steps/peer_agents.py +92 -0
- agent_env/task_step/task_steps/prompt_agent.py +900 -0
- agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
- agent_env/task_step/task_steps/register_env_triggers.py +97 -0
- agent_env/task_step/task_steps/reset_env.py +71 -0
- agent_env/task_step/task_steps/review.py +152 -0
- agent_env/task_step/task_steps/run_code.py +461 -0
- agent_env/task_step/task_steps/run_code_runner.py +52 -0
- agent_env/task_step/task_steps/run_docker_container.py +380 -0
- agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
- agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
- agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
- agent_env/task_step/task_steps/snapshot_env.py +637 -0
- agent_env/task_step/task_steps/sync_env_clock.py +132 -0
- agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
- agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
- agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
- agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
- agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
- agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
- agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
- agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
- agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
- agent_env/task_step/thread_work.py +61 -0
- agent_env/utils/__init__.py +0 -0
- agent_env/utils/card_naming.py +108 -0
- agent_env/utils/deprecation.py +109 -0
- agent_env/utils/docker_build.py +29 -0
- agent_env/utils/exec_retry.py +77 -0
- agent_env/utils/litellm_attribution.py +59 -0
- agent_env/utils/paths.py +20 -0
- agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
- agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
- agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
- agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
- agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
- agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
- agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""SQLite implementation of the DocumentStore interface (stdlib, no infra).
|
|
2
|
+
|
|
3
|
+
Stores each document as a JSON row in a per-collection table; SQLite provides
|
|
4
|
+
uniqueness (via unique indexes on ``json_extract``), atomicity (transactions),
|
|
5
|
+
and durability. ``Filter`` / ``UpdateSpec`` / ``Sort`` semantics are evaluated in
|
|
6
|
+
Python, mirroring ``mongo_document_store`` as the oracle. Datetimes are
|
|
7
|
+
serialized as ISO strings.
|
|
8
|
+
|
|
9
|
+
SQL safety note: every user-controlled *value* is bound as a ``?`` placeholder.
|
|
10
|
+
The only interpolated tokens are SQL *identifiers* (table / index / column
|
|
11
|
+
names), which cannot be passed as bound parameters. Those are derived from a
|
|
12
|
+
strict allowlist (see ``_safe_ident`` / ``_safe_path``) before interpolation, so
|
|
13
|
+
the ``execute(f"...")`` sites carry ``# nosemgrep`` for the raw-query rule.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
import sqlite3
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
from agent_env.store.document_store.document_store import (
|
|
32
|
+
DocumentStore,
|
|
33
|
+
DuplicateKeyError,
|
|
34
|
+
Filter,
|
|
35
|
+
Sort,
|
|
36
|
+
UpdateSpec,
|
|
37
|
+
)
|
|
38
|
+
from agent_env.store.document_store import evaluation
|
|
39
|
+
from agent_env.store.ids import aliases_local_encoding
|
|
40
|
+
from agent_env.store.local_state import ensure_state_dir
|
|
41
|
+
|
|
42
|
+
# SQL identifiers (table/index names) must be a bare word; JSON-path field
|
|
43
|
+
# references additionally allow dots for nested paths (e.g. ``metadata.rev``).
|
|
44
|
+
_IDENT_RE = re.compile(r"[A-Za-z0-9_]+")
|
|
45
|
+
_PATH_RE = re.compile(r"[A-Za-z0-9_.]+")
|
|
46
|
+
|
|
47
|
+
_BUSY_TIMEOUT_SECONDS = 5.0
|
|
48
|
+
_WAL_SWITCH_RETRY_DELAY_SECONDS = 0.01
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _enable_wal(conn: sqlite3.Connection) -> None:
|
|
52
|
+
"""Switch the database to WAL. Processes opening a new database at the same time all need
|
|
53
|
+
the exclusive lock this takes, and SQLite fails the losers at once instead of waiting, so
|
|
54
|
+
retry until the winner is done."""
|
|
55
|
+
deadline = time.monotonic() + _BUSY_TIMEOUT_SECONDS
|
|
56
|
+
while True:
|
|
57
|
+
try:
|
|
58
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
59
|
+
return
|
|
60
|
+
except sqlite3.OperationalError as e:
|
|
61
|
+
if e.sqlite_errorcode != sqlite3.SQLITE_BUSY or time.monotonic() >= deadline:
|
|
62
|
+
raise
|
|
63
|
+
time.sleep(_WAL_SWITCH_RETRY_DELAY_SECONDS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LocalSqliteDocumentStore(DocumentStore):
|
|
67
|
+
"""DocumentStore backed by a single stdlib-``sqlite3`` database file."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, path: str) -> None:
|
|
70
|
+
# Nothing touches disk until the first operation: constructing a store creates nothing.
|
|
71
|
+
self._path = Path(path)
|
|
72
|
+
self._connection: Optional[sqlite3.Connection] = None
|
|
73
|
+
self._lock = threading.RLock()
|
|
74
|
+
self._tables: set[str] = set()
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def path(self) -> Path:
|
|
78
|
+
return self._path
|
|
79
|
+
|
|
80
|
+
def check_id(self, entity_id: str) -> None:
|
|
81
|
+
super().check_id(entity_id)
|
|
82
|
+
if aliases_local_encoding(entity_id):
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"id {entity_id!r} is spelled like an encoded @local id, so it would share that id's objects, "
|
|
85
|
+
"images and files; a bare id in a local store can't start with 'local/' or 'local-<name>-<12 hex>'"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def _conn(self) -> sqlite3.Connection:
|
|
90
|
+
with self._lock:
|
|
91
|
+
if self._connection is None:
|
|
92
|
+
ensure_state_dir(self._path.parent)
|
|
93
|
+
conn = sqlite3.connect(
|
|
94
|
+
str(self._path), timeout=_BUSY_TIMEOUT_SECONDS, check_same_thread=False, isolation_level=None
|
|
95
|
+
)
|
|
96
|
+
_enable_wal(conn)
|
|
97
|
+
self._connection = conn
|
|
98
|
+
return self._connection
|
|
99
|
+
|
|
100
|
+
def find_one(
|
|
101
|
+
self, collection: str, filter: Filter, sort: Optional[Sort] = None
|
|
102
|
+
) -> Optional[dict]:
|
|
103
|
+
with self._lock:
|
|
104
|
+
matches = [doc for _, doc in self._load(collection) if evaluation.matches(doc, filter)]
|
|
105
|
+
matches = evaluation.sort_docs(matches, sort)
|
|
106
|
+
return matches[0] if matches else None
|
|
107
|
+
|
|
108
|
+
def query(
|
|
109
|
+
self,
|
|
110
|
+
collection: str,
|
|
111
|
+
filter: Filter,
|
|
112
|
+
sort: Optional[Sort] = None,
|
|
113
|
+
limit: Optional[int] = None,
|
|
114
|
+
offset: Optional[int] = None,
|
|
115
|
+
) -> list[dict]:
|
|
116
|
+
with self._lock:
|
|
117
|
+
docs = [doc for _, doc in self._load(collection) if evaluation.matches(doc, filter)]
|
|
118
|
+
docs = evaluation.sort_docs(docs, sort)
|
|
119
|
+
if offset:
|
|
120
|
+
docs = docs[offset:]
|
|
121
|
+
if limit:
|
|
122
|
+
docs = docs[:limit]
|
|
123
|
+
return docs
|
|
124
|
+
|
|
125
|
+
def count(self, collection: str, filter: Filter) -> int:
|
|
126
|
+
with self._lock:
|
|
127
|
+
return sum(1 for _, doc in self._load(collection) if evaluation.matches(doc, filter))
|
|
128
|
+
|
|
129
|
+
def insert(self, collection: str, doc: dict) -> None:
|
|
130
|
+
with self._lock:
|
|
131
|
+
tbl = self._ensure_table(collection)
|
|
132
|
+
self._insert_raw(tbl, dict(doc))
|
|
133
|
+
|
|
134
|
+
def update(
|
|
135
|
+
self, collection: str, filter: Filter, update: UpdateSpec, upsert: bool = False
|
|
136
|
+
) -> int:
|
|
137
|
+
update.validate()
|
|
138
|
+
with self._lock, self._immediate():
|
|
139
|
+
match = self._first_match(collection, filter)
|
|
140
|
+
if match is not None:
|
|
141
|
+
rowid, doc = match
|
|
142
|
+
evaluation.apply_update(doc, update)
|
|
143
|
+
self._conn.execute(
|
|
144
|
+
# nosemgrep: sqlalchemy-execute-raw-query -- table is a validated identifier; doc/rowid are bound
|
|
145
|
+
f'UPDATE "{self._table(collection)}" SET doc=? WHERE rowid=?',
|
|
146
|
+
(self._dumps(doc), rowid),
|
|
147
|
+
)
|
|
148
|
+
return 1
|
|
149
|
+
if upsert:
|
|
150
|
+
self._insert_raw(self._ensure_table(collection), evaluation.synthesize(filter, update))
|
|
151
|
+
return 1
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
def update_one_and_get(
|
|
155
|
+
self,
|
|
156
|
+
collection: str,
|
|
157
|
+
filter: Filter,
|
|
158
|
+
update: UpdateSpec,
|
|
159
|
+
return_after: bool = True,
|
|
160
|
+
upsert: bool = False,
|
|
161
|
+
) -> Optional[dict]:
|
|
162
|
+
update.validate()
|
|
163
|
+
with self._lock, self._immediate():
|
|
164
|
+
match = self._first_match(collection, filter)
|
|
165
|
+
if match is not None:
|
|
166
|
+
rowid, doc = match
|
|
167
|
+
before = json.loads(self._dumps(doc))
|
|
168
|
+
evaluation.apply_update(doc, update)
|
|
169
|
+
self._conn.execute(
|
|
170
|
+
# nosemgrep: sqlalchemy-execute-raw-query -- table is a validated identifier; doc/rowid are bound
|
|
171
|
+
f'UPDATE "{self._table(collection)}" SET doc=? WHERE rowid=?',
|
|
172
|
+
(self._dumps(doc), rowid),
|
|
173
|
+
)
|
|
174
|
+
return doc if return_after else before
|
|
175
|
+
if upsert:
|
|
176
|
+
new_doc = evaluation.synthesize(filter, update)
|
|
177
|
+
self._insert_raw(self._ensure_table(collection), new_doc)
|
|
178
|
+
return new_doc if return_after else None
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def replace(
|
|
182
|
+
self, collection: str, filter: Filter, doc: dict, upsert: bool = False
|
|
183
|
+
) -> int:
|
|
184
|
+
with self._lock, self._immediate():
|
|
185
|
+
match = self._first_match(collection, filter)
|
|
186
|
+
if match is not None:
|
|
187
|
+
rowid, _ = match
|
|
188
|
+
self._conn.execute(
|
|
189
|
+
# nosemgrep: sqlalchemy-execute-raw-query -- table is a validated identifier; doc/rowid are bound
|
|
190
|
+
f'UPDATE "{self._table(collection)}" SET doc=? WHERE rowid=?',
|
|
191
|
+
(self._dumps(dict(doc)), rowid),
|
|
192
|
+
)
|
|
193
|
+
return 1
|
|
194
|
+
if upsert:
|
|
195
|
+
self._insert_raw(self._ensure_table(collection), dict(doc))
|
|
196
|
+
return 1
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
def delete(self, collection: str, filter: Filter) -> int:
|
|
200
|
+
with self._lock, self._immediate():
|
|
201
|
+
match = self._first_match(collection, filter)
|
|
202
|
+
if match is None:
|
|
203
|
+
return 0
|
|
204
|
+
rowid, _ = match
|
|
205
|
+
# nosemgrep: sqlalchemy-execute-raw-query -- table is a validated identifier; rowid is bound
|
|
206
|
+
self._conn.execute(f'DELETE FROM "{self._table(collection)}" WHERE rowid=?', (rowid,))
|
|
207
|
+
return 1
|
|
208
|
+
|
|
209
|
+
def ensure_index(
|
|
210
|
+
self,
|
|
211
|
+
collection: str,
|
|
212
|
+
fields: list[str],
|
|
213
|
+
unique: bool = False,
|
|
214
|
+
ttl_seconds: Optional[int] = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
if ttl_seconds is not None:
|
|
217
|
+
logger.warning(
|
|
218
|
+
"LocalSqliteDocumentStore has no TTL support; ttl_seconds=%s on %s%s ignored — rows will not auto-expire.",
|
|
219
|
+
ttl_seconds, collection, fields,
|
|
220
|
+
)
|
|
221
|
+
with self._lock:
|
|
222
|
+
tbl = self._ensure_table(collection)
|
|
223
|
+
raw_name = tbl + "_" + "_".join(fields) + ("_unique" if unique else "_index")
|
|
224
|
+
name = self._safe_ident(re.sub(r"\W", "_", raw_name))
|
|
225
|
+
cols = ", ".join(f"json_extract(doc, '$.{self._safe_path(f)}')" for f in fields)
|
|
226
|
+
uniq = "UNIQUE " if unique else ""
|
|
227
|
+
self._conn.execute(
|
|
228
|
+
# nosemgrep: sqlalchemy-execute-raw-query -- name/tbl/cols are validated identifiers
|
|
229
|
+
f'CREATE {uniq}INDEX IF NOT EXISTS "{name}" ON "{tbl}" ({cols})'
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# --- sqlite plumbing ---
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _safe_ident(name: str) -> str:
|
|
236
|
+
"""Validate a SQL identifier (table/index name) against a strict allowlist.
|
|
237
|
+
|
|
238
|
+
Identifiers cannot be passed as bound parameters, so we assert they are a
|
|
239
|
+
bare ``[A-Za-z0-9_]`` word before interpolating them into SQL. User input
|
|
240
|
+
only ever reaches SQL as a bound ``?`` value, never as an identifier.
|
|
241
|
+
"""
|
|
242
|
+
if not _IDENT_RE.fullmatch(name):
|
|
243
|
+
raise ValueError(f"unsafe SQL identifier: {name!r}")
|
|
244
|
+
return name
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _safe_path(field: str) -> str:
|
|
248
|
+
"""Validate a JSON-path field reference used inside ``json_extract('$.…')``."""
|
|
249
|
+
if not _PATH_RE.fullmatch(field):
|
|
250
|
+
raise ValueError(f"unsafe JSON field path: {field!r}")
|
|
251
|
+
return field
|
|
252
|
+
|
|
253
|
+
def _table(self, collection: str) -> str:
|
|
254
|
+
# The table name is an identifier (not bindable): sanitize the collection
|
|
255
|
+
# to a bare word, then assert the allowlist as defense-in-depth.
|
|
256
|
+
return self._safe_ident("docs_" + re.sub(r"\W", "_", collection))
|
|
257
|
+
|
|
258
|
+
def _ensure_table(self, collection: str) -> str:
|
|
259
|
+
tbl = self._table(collection)
|
|
260
|
+
if tbl not in self._tables:
|
|
261
|
+
# nosemgrep: sqlalchemy-execute-raw-query -- tbl is a validated identifier
|
|
262
|
+
self._conn.execute(f'CREATE TABLE IF NOT EXISTS "{tbl}" (doc TEXT NOT NULL)')
|
|
263
|
+
self._tables.add(tbl)
|
|
264
|
+
return tbl
|
|
265
|
+
|
|
266
|
+
def _adopt_if_created(self, tbl: str) -> bool:
|
|
267
|
+
"""Adopt a table that exists on disk but is not yet in ``self._tables``."""
|
|
268
|
+
if self._conn.execute(
|
|
269
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (tbl,)
|
|
270
|
+
).fetchone():
|
|
271
|
+
self._tables.add(tbl)
|
|
272
|
+
return True
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
def _load(self, collection: str) -> list[tuple[int, dict]]:
|
|
276
|
+
tbl = self._table(collection)
|
|
277
|
+
if tbl not in self._tables and not self._adopt_if_created(tbl):
|
|
278
|
+
return []
|
|
279
|
+
return [
|
|
280
|
+
(row[0], json.loads(row[1]))
|
|
281
|
+
# nosemgrep: sqlalchemy-execute-raw-query -- tbl is a validated identifier
|
|
282
|
+
for row in self._conn.execute(f'SELECT rowid, doc FROM "{tbl}"')
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
def _first_match(self, collection: str, filter: Filter) -> Optional[tuple[int, dict]]:
|
|
286
|
+
for rowid, doc in self._load(collection):
|
|
287
|
+
if evaluation.matches(doc, filter):
|
|
288
|
+
return rowid, doc
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
@contextmanager
|
|
292
|
+
def _immediate(self):
|
|
293
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
294
|
+
try:
|
|
295
|
+
yield
|
|
296
|
+
self._conn.execute("COMMIT")
|
|
297
|
+
except BaseException:
|
|
298
|
+
self._conn.execute("ROLLBACK")
|
|
299
|
+
raise
|
|
300
|
+
|
|
301
|
+
def _insert_raw(self, tbl: str, doc: dict) -> None:
|
|
302
|
+
try:
|
|
303
|
+
# nosemgrep: sqlalchemy-execute-raw-query -- tbl is a validated identifier; doc is bound
|
|
304
|
+
self._conn.execute(f'INSERT INTO "{tbl}"(doc) VALUES(?)', (self._dumps(doc),))
|
|
305
|
+
except sqlite3.IntegrityError as e:
|
|
306
|
+
raise DuplicateKeyError(str(e)) from e
|
|
307
|
+
|
|
308
|
+
def _json_default(self, o):
|
|
309
|
+
if isinstance(o, datetime):
|
|
310
|
+
return o.isoformat()
|
|
311
|
+
raise TypeError(f"Object of type {type(o).__name__} is not JSON-serializable")
|
|
312
|
+
|
|
313
|
+
def _dumps(self, doc: dict) -> str:
|
|
314
|
+
return json.dumps(doc, default=self._json_default)
|
agent_env/store/ids.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Namespaced entity ids, and the object keys, image repositories and filenames derived from them.
|
|
2
|
+
|
|
3
|
+
An id opening ``@<namespace>/`` names the store it lives in; ``@local/…`` is the only namespace
|
|
4
|
+
today. Documents keep an id verbatim. Everything derived from an ``@local`` id uses one bounded
|
|
5
|
+
segment, ``local/<slug>-<sha256(id)[:12]>``, and every other id passes through byte-identical.
|
|
6
|
+
The encoders never raise: they expect ids that ``validate_local_id`` accepts, and still return a
|
|
7
|
+
safe segment for any other input. An id may contain spaces, ``&`` and parentheses, so quote one
|
|
8
|
+
before it reaches a shell or a URL, and derive object keys, image repositories and filenames with
|
|
9
|
+
the helpers here rather than from the raw id.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import re
|
|
16
|
+
import unicodedata
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
LOCAL_NAMESPACE = "local"
|
|
20
|
+
LOCAL_PREFIX = f"@{LOCAL_NAMESPACE}/"
|
|
21
|
+
MAX_LOCAL_ID_BYTES = 4096
|
|
22
|
+
|
|
23
|
+
_SLUG_LENGTH = 48
|
|
24
|
+
_HASH_LENGTH = 12
|
|
25
|
+
_NON_SLUG_RUN = re.compile(r"[^a-z0-9]+")
|
|
26
|
+
_ENCODED_LOCAL_ID = re.compile(
|
|
27
|
+
rf"{LOCAL_NAMESPACE}(?:/|-(?:[a-z0-9]+(?:-[a-z0-9]+)*-)?[0-9a-f]{{{_HASH_LENGTH}}}(?:/|$))"
|
|
28
|
+
)
|
|
29
|
+
_ID_PUNCTUATION = frozenset(" ._-~/@()+,&")
|
|
30
|
+
_ID_CATEGORIES = frozenset("LNM")
|
|
31
|
+
_INVISIBLE = re.compile(
|
|
32
|
+
"[\u034f\u115f\u1160\u17b4\u17b5\u180b-\u180d\u180f\u3164\ufe00-\ufe0f\uffa0\U000e0100-\U000e01ef]"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_namespace(entity_id: str) -> Optional[str]:
|
|
37
|
+
"""The namespace an ``@<namespace>/…`` id names, or None for a bare id."""
|
|
38
|
+
if not entity_id.startswith("@"):
|
|
39
|
+
return None
|
|
40
|
+
return entity_id[1:].split("/", 1)[0]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_local_id(entity_id: str) -> bool:
|
|
44
|
+
return entity_id.startswith(LOCAL_PREFIX)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_local_id(entity_id: str) -> None:
|
|
48
|
+
"""Raise ValueError unless ``entity_id`` is ``@local/`` followed by non-empty path segments,
|
|
49
|
+
none of them ``.`` or ``..`` or opening or closing with a space, made only of letters, digits,
|
|
50
|
+
spaces and ``. _ - ~ / @ ( ) + , &``, and at most 4096 bytes of UTF-8 in all (Linux's PATH_MAX).
|
|
51
|
+
Only the leading ``@`` names a namespace, so one inside a segment is an ordinary character.
|
|
52
|
+
|
|
53
|
+
Letters include every script's, with its combining marks, as long as each mark follows a
|
|
54
|
+
letter, digit or mark. The invisible (default-ignorable) letters and marks are refused."""
|
|
55
|
+
if not entity_id.startswith(LOCAL_PREFIX):
|
|
56
|
+
raise ValueError(f"local id {entity_id!r} must start with {LOCAL_PREFIX!r}")
|
|
57
|
+
size = len(entity_id.encode("utf-8", "surrogatepass"))
|
|
58
|
+
if size > MAX_LOCAL_ID_BYTES:
|
|
59
|
+
raise ValueError(f"local id is {size} bytes; the limit is {MAX_LOCAL_ID_BYTES}: {entity_id[:64]!r}…")
|
|
60
|
+
disallowed = sorted({c for c in entity_id[len(LOCAL_PREFIX):] if not _allowed_in_id(c)})
|
|
61
|
+
if disallowed:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"local id {entity_id!r} contains {ascii(''.join(disallowed))}; "
|
|
64
|
+
"a local id may contain only letters, digits, spaces and . _ - ~ / @ ( ) + , &"
|
|
65
|
+
)
|
|
66
|
+
segments = entity_id[len(LOCAL_PREFIX):].split("/")
|
|
67
|
+
if any(segment in ("", ".", "..") for segment in segments):
|
|
68
|
+
raise ValueError(f"local id {entity_id!r} has an empty, '.' or '..' path segment")
|
|
69
|
+
if any(segment != segment.strip(" ") for segment in segments):
|
|
70
|
+
raise ValueError(f"local id {entity_id!r} has a path segment that opens or closes with a space")
|
|
71
|
+
if any(_detached_mark(segment) for segment in segments):
|
|
72
|
+
raise ValueError(f"local id {entity_id!r} has a combining mark that does not follow a letter or digit")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _allowed_in_id(character: str) -> bool:
|
|
76
|
+
if _INVISIBLE.match(character):
|
|
77
|
+
return False
|
|
78
|
+
return character in _ID_PUNCTUATION or unicodedata.category(character)[0] in _ID_CATEGORIES
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _detached_mark(segment: str) -> bool:
|
|
82
|
+
categories = [unicodedata.category(character)[0] for character in segment]
|
|
83
|
+
return any(this == "M" and before not in _ID_CATEGORIES for before, this in zip(["Z", *categories], categories))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _local_segment(entity_id: str) -> str:
|
|
87
|
+
digest = hashlib.sha256(entity_id.encode("utf-8", "surrogatepass")).hexdigest()[:_HASH_LENGTH]
|
|
88
|
+
decomposed = unicodedata.normalize("NFKD", entity_id.removeprefix(LOCAL_PREFIX))
|
|
89
|
+
folded = "".join(c for c in decomposed if not unicodedata.combining(c)).casefold().replace("~", "")
|
|
90
|
+
slug = _NON_SLUG_RUN.sub("-", folded).strip("-")[:_SLUG_LENGTH].rstrip("-")
|
|
91
|
+
return f"{LOCAL_NAMESPACE}/{slug}-{digest}" if slug else f"{LOCAL_NAMESPACE}/{digest}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def aliases_local_encoding(entity_id: str) -> bool:
|
|
95
|
+
"""Whether a bare id is spelled like an ``@local`` id's encoding: ``local/…``, or the filename form
|
|
96
|
+
``local-<slug>-<hash>`` alone or opening a path. Its objects, images and files would take that id's."""
|
|
97
|
+
return _ENCODED_LOCAL_ID.match(entity_id) is not None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def key_segment(entity_id: str) -> str:
|
|
101
|
+
"""The object-key segment for ``entity_id`` (``artifacts/<type>/<segment>/<version>/…``)."""
|
|
102
|
+
return _local_segment(entity_id) if is_local_id(entity_id) else entity_id
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def image_repository(entity_id: str) -> str:
|
|
106
|
+
"""The image repository for ``entity_id``; an ``@local`` id yields a valid OCI repository path."""
|
|
107
|
+
return key_segment(entity_id)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fs_safe(entity_id: str) -> str:
|
|
111
|
+
"""A filename for an ``@local`` id: its key segment with no ``/``. Any other id is returned
|
|
112
|
+
unchanged, so it is only as filename-safe as it already was."""
|
|
113
|
+
return _local_segment(entity_id).replace("/", "-", 1) if is_local_id(entity_id) else entity_id
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# The longest id derive_id makes is a skill's or CLI's per-file artifact, ``{id}__files__<16 hex>``,
|
|
117
|
+
# so an authored id leaves room for those 25 bytes.
|
|
118
|
+
MAX_AUTHORED_LOCAL_ID_BYTES = MAX_LOCAL_ID_BYTES - len("__files__") - 16
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def derive_id(base: str, suffix: str) -> str:
|
|
122
|
+
"""The id of an entity derived from ``base``: a suffix, so the base's namespace carries over."""
|
|
123
|
+
return f"{base}__{suffix}"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Backend-agnostic image (registry) store abstraction + its implementations.
|
|
2
|
+
|
|
3
|
+
``image_store`` defines the abstractions and generic OCI implementation;
|
|
4
|
+
``ecr_image_store`` and ``local_registry_image_store`` retain compatibility names.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from agent_env.store.image_store.ecr_image_store import EcrCredentials, EcrImageStore
|
|
8
|
+
from agent_env.store.image_store.image_store import (
|
|
9
|
+
ImageStore,
|
|
10
|
+
OciRegistryImageStore,
|
|
11
|
+
)
|
|
12
|
+
from agent_env.store.image_store.local_registry_image_store import (
|
|
13
|
+
LocalRegistryImageStore,
|
|
14
|
+
)
|
|
15
|
+
from agent_env.store.image_store.oci_registry_credentials import (
|
|
16
|
+
OciRegistryCredentials,
|
|
17
|
+
RegistryAuth,
|
|
18
|
+
SecretStoreCredentials,
|
|
19
|
+
normalize_registry_host,
|
|
20
|
+
registry_host_from_ref,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ImageStore",
|
|
25
|
+
"OciRegistryImageStore",
|
|
26
|
+
"RegistryAuth",
|
|
27
|
+
"OciRegistryCredentials",
|
|
28
|
+
"normalize_registry_host",
|
|
29
|
+
"registry_host_from_ref",
|
|
30
|
+
"EcrCredentials",
|
|
31
|
+
"SecretStoreCredentials",
|
|
32
|
+
"EcrImageStore",
|
|
33
|
+
"LocalRegistryImageStore",
|
|
34
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""ECR implementation of the ImageStore interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import boto3
|
|
11
|
+
from botocore.exceptions import ClientError
|
|
12
|
+
|
|
13
|
+
from agent_env.config.errors import ConfigError
|
|
14
|
+
from agent_env.store.image_store.image_store import OciRegistryImageStore
|
|
15
|
+
from agent_env.store.image_store.oci_registry_credentials import (
|
|
16
|
+
OciRegistryCredentials,
|
|
17
|
+
RegistryAuth,
|
|
18
|
+
normalize_registry_host,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EcrCredentials(OciRegistryCredentials):
|
|
25
|
+
"""Mint short-lived Docker credentials through the AWS ECR API."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
region: str | None = None,
|
|
30
|
+
access_key: str | None = None,
|
|
31
|
+
secret_key: str | None = None,
|
|
32
|
+
*,
|
|
33
|
+
client: Any = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
if (access_key is None) != (secret_key is None):
|
|
36
|
+
raise ValueError("access_key and secret_key must be provided together")
|
|
37
|
+
self._region = region
|
|
38
|
+
self._access_key = access_key
|
|
39
|
+
self._secret_key = secret_key
|
|
40
|
+
self._client = client
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_config(
|
|
44
|
+
cls,
|
|
45
|
+
*,
|
|
46
|
+
region: str | None = None,
|
|
47
|
+
access_key: str | None = None,
|
|
48
|
+
secret_key: str | None = None,
|
|
49
|
+
) -> EcrCredentials:
|
|
50
|
+
"""Build explicitly configured ECR credentials.
|
|
51
|
+
|
|
52
|
+
The keyless constructor (the legacy ``EcrImageStore`` default) resolves
|
|
53
|
+
the AWS default credential chain; generic OCI configuration must name its
|
|
54
|
+
credential inputs.
|
|
55
|
+
"""
|
|
56
|
+
if not region or not access_key or not secret_key:
|
|
57
|
+
raise ValueError("region, access_key, and secret_key must be configured")
|
|
58
|
+
return cls(region=region, access_key=access_key, secret_key=secret_key)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def client(self):
|
|
62
|
+
if self._client is None:
|
|
63
|
+
if self._region is None:
|
|
64
|
+
raise ConfigError(
|
|
65
|
+
"EcrCredentials has no region: configure it on the store's "
|
|
66
|
+
"credentials (e.g. [stores.image.config] region)."
|
|
67
|
+
)
|
|
68
|
+
if self._access_key is None:
|
|
69
|
+
self._client = boto3.client("ecr", region_name=self._region)
|
|
70
|
+
else:
|
|
71
|
+
self._client = boto3.client(
|
|
72
|
+
"ecr",
|
|
73
|
+
region_name=self._region,
|
|
74
|
+
aws_access_key_id=self._access_key,
|
|
75
|
+
aws_secret_access_key=self._secret_key,
|
|
76
|
+
)
|
|
77
|
+
return self._client
|
|
78
|
+
|
|
79
|
+
def mint(self, host: str) -> RegistryAuth:
|
|
80
|
+
token = self.client.get_authorization_token()["authorizationData"][0][
|
|
81
|
+
"authorizationToken"
|
|
82
|
+
]
|
|
83
|
+
try:
|
|
84
|
+
username, password = (
|
|
85
|
+
base64.b64decode(token, validate=True).decode().split(":", 1)
|
|
86
|
+
)
|
|
87
|
+
except (binascii.Error, UnicodeDecodeError, ValueError):
|
|
88
|
+
raise ValueError("ECR returned an invalid authorization token") from None
|
|
89
|
+
return RegistryAuth(
|
|
90
|
+
registry=normalize_registry_host(host), username=username, password=password
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class EcrImageStore(OciRegistryImageStore):
|
|
95
|
+
"""ImageStore backed by AWS ECR; the client is built lazily on the first ECR op."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
registry_host: str,
|
|
100
|
+
repository_prefix: str = "",
|
|
101
|
+
*,
|
|
102
|
+
credentials: EcrCredentials | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
if credentials is not None and not isinstance(credentials, EcrCredentials):
|
|
105
|
+
raise TypeError(
|
|
106
|
+
"EcrImageStore credentials must be an EcrCredentials instance"
|
|
107
|
+
)
|
|
108
|
+
self._ecr_credentials = credentials if credentials is not None else EcrCredentials()
|
|
109
|
+
super().__init__(
|
|
110
|
+
registry_host=registry_host,
|
|
111
|
+
repository_prefix=repository_prefix,
|
|
112
|
+
credentials=self._ecr_credentials,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def _ecr(self):
|
|
117
|
+
return self._ecr_credentials.client
|
|
118
|
+
|
|
119
|
+
def ensure_repository(self, repository: str) -> None:
|
|
120
|
+
name = self._repo_name(repository)
|
|
121
|
+
try:
|
|
122
|
+
self._ecr.create_repository(repositoryName=name)
|
|
123
|
+
logger.info(f"Created ECR repository {name}")
|
|
124
|
+
except self._ecr.exceptions.RepositoryAlreadyExistsException:
|
|
125
|
+
pass
|
|
126
|
+
except ClientError as e:
|
|
127
|
+
if e.response["Error"]["Code"] != "AccessDeniedException":
|
|
128
|
+
raise
|
|
129
|
+
logger.info(
|
|
130
|
+
f"No CreateRepository permission for {name}; assuming it exists"
|
|
131
|
+
)
|