diff --git a/README.zh-CN.md b/README.zh-CN.md index 87d0d73..5e1f012 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,59 +44,3 @@ python -m pytest -q | **Azure** | `QSL_CLOUD_PROVIDER=azure` | 使用 Azure Key Vault、Blob Storage、Cosmos DB—— 需要 azure-identity 和 Azure SDK。 | | **本地文件系统** | `QSL_CLOUD_PROVIDER=local` | 密钥和数据库存储在 `~/.qsl/` 目录下。无需任何云凭证——适合开发、测试和离线环境。 | | **环境变量** | `QSL_CLOUD_PROVIDER=env` | 密钥从环境变量读取;其余操作使用本地文件系统。适合 CI 场景。 | - -**用法:** - -```python -from quant_platform_kit.cloud import ( - get_secret_store, # SecretStore(只读) - get_secret_store_rw, # SecretStoreReadWrite(令牌刷新等写场景) - get_object_store, # ObjectStore(GCS / S3 / 本地文件) - get_document_store, # DocumentStore(Firestore / JSON 文件) - get_compute_discovery, # ComputeDiscovery(GCE / 环境变量) - get_deployment_context, # DeploymentContext(Cloud Run / 本地 mock) -) - -# 读取密钥——无论后端是 GCP、环境变量还是 ~/.qsl/secrets/ 都可以 -secret = get_secret_store().get_secret("my-api-key") - -# 读写对象——URI 格式与 provider 无关 -data = get_object_store().read_text("gs://bucket/path/to/data.json") -get_object_store().write_text("gs://bucket/path/to/output.json", '{"key": "value"}') -``` - -切换 Provider 只需设置 `QSL_CLOUD_PROVIDER` 环境变量: - -```bash -export QSL_CLOUD_PROVIDER=local # 所有云操作走 ~/.qsl/ 本地目录 -python your_script.py -``` - -令牌刷新场景(如 LongPort 或 Schwab OAuth 自动续期)使用读写版接口: - -```python -from quant_platform_kit.cloud import get_secret_store_rw -rw = get_secret_store_rw() -rw.update_secret("my-token", "new-token-value") -``` - -## 延伸文档 - -- [`docs/platform_notification_outcomes.md`](docs/platform_notification_outcomes.md) -- [`docs/platform_notification_outcomes.zh-CN.md`](docs/platform_notification_outcomes.zh-CN.md) -- [`docs/platform_repo_boundaries.md`](docs/platform_repo_boundaries.md) -- [`docs/platform_repo_boundaries.zh-CN.md`](docs/platform_repo_boundaries.zh-CN.md) -- [`docs/quantconnect.md`](docs/quantconnect.md) -- [`docs/strategy_plugin_runtime_contract.md`](docs/strategy_plugin_runtime_contract.md) -- [`docs/strategy_plugin_runtime_contract.zh-CN.md`](docs/strategy_plugin_runtime_contract.zh-CN.md) -- [`docs/us_equity_cross_platform_strategy_spec.md`](docs/us_equity_cross_platform_strategy_spec.md) - -## 社区和安全 - -- 贡献前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md),确认 PR 范围、本地校验和文档要求。 -- 讨论、issue 和 review 请遵守 [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)。 -- 涉及密钥、自动化、券商/交易所或云资源的漏洞请按 [SECURITY.md](SECURITY.md) 私密报告;不要为 secret 或实盘风险开公开 issue。 - -## 许可证 - -详见 [LICENSE](LICENSE)。 diff --git a/src/quant_platform_kit/cloud/aws_provider.py b/src/quant_platform_kit/cloud/aws_provider.py index 2369f09..cabc066 100644 --- a/src/quant_platform_kit/cloud/aws_provider.py +++ b/src/quant_platform_kit/cloud/aws_provider.py @@ -242,21 +242,65 @@ def resolve_instance_ip( class AwsDeploymentContext: - """AWS deployment context.""" + """AWS deployment context. + + 通过 ECS metadata / EC2 IMDSv2 获取运行时身份信息。 + + 注意:fetch_id_token 的语义与 GCP 不完全对等 —— + AWS 没有 GCP ID Token 的标准替代品。 + 此实现返回当前 EC2/ECS 实例的身份文档, + 或 STS GetCallerIdentity 作为 fallback。 + """ @property def project_id(self) -> str: - return os.environ.get("AWS_ACCOUNT_ID", "") + # ECS task ARN 解析 → account_id + task_arn = os.environ.get("ECS_CONTAINER_METADATA_URI_V4", "") + if task_arn: + parts = task_arn.split(":") if ":" in task_arn else [] + if len(parts) >= 5: + return parts[4] + return _resolve_aws_account_id() @property def region(self) -> str | None: return _resolve_aws_region() def fetch_id_token(self, audience: str) -> str: - raise NotImplementedError( - "AWS DeploymentContext.fetch_id_token is not implemented. " - "Use a service-specific mechanism (e.g., Cognito, STS, or IAM roles)." - ) + """获取当前 AWS 环境的身份凭证。 + + 在 ECS/EC2 上返回实例身份文档的 JSON string; + 本地开发环境 fallback 到 STS GetCallerIdentity。 + + ``audience`` 参数在 AWS 无直接对应,此方法不会使用它。 + 如果调用方依赖 audience 进行令牌验证, + 建议使用 Cognito 或自建 OIDC provider。 + """ + import json as _json + try: + return _fetch_ecs_identity() + except Exception: + pass + try: + return _fetch_ec2_identity() + except Exception: + pass + try: + import boto3 + sts = boto3.client("sts", region_name=_resolve_aws_region()) + identity = sts.get_caller_identity() + return _json.dumps({ + "account": identity.get("Account", ""), + "arn": identity.get("Arn", ""), + "user_id": identity.get("UserId", ""), + }) + except Exception as exc: + raise RuntimeError( + "AwsDeploymentContext.fetch_id_token: unable to resolve AWS identity. " + "Ensure the process runs on EC2, ECS, or has valid AWS credentials. " + "Note: GCP-style audience-based ID tokens are not natively supported on AWS; " + "consider using Cognito or a custom OIDC setup for audienced tokens." + ) from exc # ══════════════════════════════════════════════════════════════════════ @@ -279,6 +323,54 @@ def _resolve_aws_region() -> str: return "us-east-1" +def _resolve_aws_account_id() -> str: + """Resolve AWS account ID from env vars, STS, or metadata.""" + env = os.environ.get("AWS_ACCOUNT_ID") + if env: + return env + try: + import boto3 + sts = boto3.client("sts", region_name=_resolve_aws_region()) + return sts.get_caller_identity().get("Account", "") + except Exception: + return "" + + +def _fetch_ecs_identity() -> str: + """Fetch identity document from ECS container metadata endpoint (v4).""" + import urllib.request + metadata_uri = os.environ.get("ECS_CONTAINER_METADATA_URI_V4", "") + if not metadata_uri: + raise RuntimeError("Not running on ECS (ECS_CONTAINER_METADATA_URI_V4 not set)") + req = urllib.request.Request(metadata_uri) + with urllib.request.urlopen(req, timeout=3) as resp: + return resp.read().decode("utf-8") + + +def _fetch_ec2_identity() -> str: + """Fetch identity document from EC2 IMDSv2.""" + import urllib.request + # Step 1: get IMDSv2 token + token_req = urllib.request.Request( + "http://169.254.169.254/latest/api/token", + headers={"X-aws-ec2-metadata-token-ttl-seconds": "60"}, + method="PUT", + ) + try: + with urllib.request.urlopen(token_req, timeout=2) as resp: + token = resp.read().decode("utf-8") + except Exception: + raise RuntimeError("Not running on EC2 (IMDSv2 unreachable)") + + # Step 2: fetch identity document + id_req = urllib.request.Request( + "http://169.254.169.254/latest/dynamic/instance-identity/document", + headers={"X-aws-ec2-metadata-token": token}, + ) + with urllib.request.urlopen(id_req, timeout=3) as resp: + return resp.read().decode("utf-8") + + def _dynamodb_serialize(value): """Convert Python values to DynamoDB-compatible format.""" if isinstance(value, (str, bool, int, float, type(None))): diff --git a/src/quant_platform_kit/cloud/env_provider.py b/src/quant_platform_kit/cloud/env_provider.py index 117feae..0d47460 100644 --- a/src/quant_platform_kit/cloud/env_provider.py +++ b/src/quant_platform_kit/cloud/env_provider.py @@ -1,11 +1,20 @@ """ Environment Variable provider — 从 os.environ 读取配置。 -适合简单部署场景或 CI 环境,无需云服务 SDK。 + +适合简单部署场景或 CI 环境,无需任何云服务 SDK。 用法: QSL_CLOUD_PROVIDER=env 密钥通过环境变量注入(大写+下划线格式) - 对象存储通过本地文件系统(类似 local_provider) + 对象存储通过本地文件系统(与 local_provider 共享实现) + +设计说明: + - SecretStore: 从环境变量读取(优先 QSL_SECRET_,其次 ) + - SecretStoreReadWrite: 只读,写操作 noop(env vars 不可运行时写入) + - ObjectStore / DocumentStore / ComputeDiscovery / DeploymentContext: + 直接复用 local_provider 实现。原因:env provider 的定位是"零依赖", + 不引入任何云 SDK。对象存储和文档数据库不适合通过环境变量操作, + local 文件系统是开发者/CI 场景下最合理的后端。 """ from __future__ import annotations @@ -21,7 +30,8 @@ LocalDeploymentContext, ) -# Re-use local implementations for object store / doc store / compute +# Re-use local implementations — by design, not a gap. +# In CI and local dev, a real cloud bucket is unnecessary; a tmp dir suffices. ObjectStore = LocalObjectStore DocumentStore = LocalDocumentStore ComputeDiscovery = LocalComputeDiscovery @@ -54,13 +64,12 @@ def get_secret(self, secret_name: str, *, project_id: str | None = None) -> str: class EnvSecretStoreReadWrite: - """只读 + 占位写操作(env var 不支持写入返回 noop)。""" + """只读 + 占位写操作(env var 不支持运行时写入)。""" def get_secret(self, secret_name: str, *, project_id: str | None = None) -> str: return EnvSecretStore().get_secret(secret_name, project_id=project_id) def create_secret(self, secret_name: str, payload: str, *, project_id: str | None = None) -> str: - # env vars 不可写,仅记录日志 import logging logging.getLogger(__name__).warning( "EnvSecretStore: create_secret is a no-op (env vars cannot be written at runtime). " diff --git a/src/quant_platform_kit/cloud/ports.py b/src/quant_platform_kit/cloud/ports.py index 33fd6e9..449646c 100644 --- a/src/quant_platform_kit/cloud/ports.py +++ b/src/quant_platform_kit/cloud/ports.py @@ -3,7 +3,7 @@ 每个 Protocol 定义一个云服务品类(密钥管理、对象存储、文档数据库等), GcpProvider 是默认实现(保持现有行为不变), -社区可通过 env PROVIDER=aws|local 切换到其他实现。 +社区可通过 env PROVIDER=aws|azure|local|env 切换到其他实现。 """ from __future__ import annotations @@ -42,16 +42,33 @@ def destroy_latest_secret(self, secret_name: str, *, project_id: str | None = No # ────────────────────────────────────────────────────────────────────── -# Object Store — 对象存储(GCS / S3 / 本地文件系统) +# Object Store — 对象存储(GCS / S3 / Azure Blob / 本地文件系统) # ────────────────────────────────────────────────────────────────────── @runtime_checkable class ObjectStore(Protocol): """对象存储接口。URI 格式由实现方决定: - - GCP: gs://bucket/key - - AWS: s3://bucket/key - - Azure: az://account/container/blob - - Local: file:///absolute/path 或 /absolute/path + + GCP provider: + gs://bucket-name/path/to/key — Cloud Storage + + AWS provider: + s3://bucket-name/path/to/key — S3 + + Azure provider: + az://account/container/blob — Blob Storage + + Local / Env provider: + /absolute/path/to/file — 本地文件(绝对路径) + file:///absolute/path/to/file — 同上 + gs://bucket/key — 映射到 ~/.qsl/storage/gs/bucket/key + s3://bucket/key — 映射到 ~/.qsl/storage/s3/bucket/key + az://account/container/blob — 映射到 ~/.qsl/storage/az/account/container/blob + + 注意:URI scheme 与 provider 必须匹配: + - ``s3://`` URI + AWS provider → S3 操作 + - ``s3://`` URI + Local provider → 本地文件模拟(便于开发) + - ``gs://`` URI + AWS provider → ValueError(不跨云互通) """ def read_text(self, uri: str) -> str: @@ -80,7 +97,7 @@ def list(self, prefix: str) -> list[str]: # ────────────────────────────────────────────────────────────────────── -# Document Store — 文档型 KV(Firestore / DynamoDB) +# Document Store — 文档型 KV(Firestore / DynamoDB / Cosmos DB) # ────────────────────────────────────────────────────────────────────── @runtime_checkable @@ -105,7 +122,7 @@ def delete(self, collection: str, document_id: str) -> None: # ────────────────────────────────────────────────────────────────────── -# Compute Discovery — 计算资源发现(GCE / EC2) +# Compute Discovery — 计算资源发现(GCE / EC2 / Azure VM) # ────────────────────────────────────────────────────────────────────── @runtime_checkable @@ -125,7 +142,7 @@ def resolve_instance_ip( # ────────────────────────────────────────────────────────────────────── -# Deployment Context — 部署上下文(Cloud Run / ECS / 自托管) +# Deployment Context — 部署上下文(Cloud Run / ECS / Azure CA / 自托管) # ────────────────────────────────────────────────────────────────────── @runtime_checkable