contree-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contree_mcp/__init__.py +0 -0
- contree_mcp/__main__.py +25 -0
- contree_mcp/app.py +240 -0
- contree_mcp/arguments.py +35 -0
- contree_mcp/auth/__init__.py +2 -0
- contree_mcp/auth/registry.py +236 -0
- contree_mcp/backend_types.py +301 -0
- contree_mcp/cache.py +208 -0
- contree_mcp/client.py +711 -0
- contree_mcp/context.py +53 -0
- contree_mcp/docs.py +1203 -0
- contree_mcp/file_cache.py +381 -0
- contree_mcp/prompts.py +238 -0
- contree_mcp/py.typed +0 -0
- contree_mcp/resources/__init__.py +17 -0
- contree_mcp/resources/guide.py +715 -0
- contree_mcp/resources/image_lineage.py +46 -0
- contree_mcp/resources/image_ls.py +32 -0
- contree_mcp/resources/import_operation.py +52 -0
- contree_mcp/resources/instance_operation.py +52 -0
- contree_mcp/resources/read_file.py +33 -0
- contree_mcp/resources/static.py +12 -0
- contree_mcp/server.py +77 -0
- contree_mcp/tools/__init__.py +39 -0
- contree_mcp/tools/cancel_operation.py +36 -0
- contree_mcp/tools/download.py +128 -0
- contree_mcp/tools/get_guide.py +54 -0
- contree_mcp/tools/get_image.py +30 -0
- contree_mcp/tools/get_operation.py +26 -0
- contree_mcp/tools/import_image.py +99 -0
- contree_mcp/tools/list_files.py +80 -0
- contree_mcp/tools/list_images.py +50 -0
- contree_mcp/tools/list_operations.py +46 -0
- contree_mcp/tools/read_file.py +47 -0
- contree_mcp/tools/registry_auth.py +71 -0
- contree_mcp/tools/registry_token_obtain.py +80 -0
- contree_mcp/tools/rsync.py +46 -0
- contree_mcp/tools/run.py +97 -0
- contree_mcp/tools/set_tag.py +31 -0
- contree_mcp/tools/upload.py +50 -0
- contree_mcp/tools/wait_operations.py +79 -0
- contree_mcp-0.1.0.dist-info/METADATA +450 -0
- contree_mcp-0.1.0.dist-info/RECORD +46 -0
- contree_mcp-0.1.0.dist-info/WHEEL +4 -0
- contree_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- contree_mcp-0.1.0.dist-info/licenses/LICENSE +176 -0
contree_mcp/__init__.py
ADDED
|
File without changes
|
contree_mcp/__main__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from contree_mcp.arguments import Parser
|
|
7
|
+
from contree_mcp.server import amain
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = Parser(
|
|
12
|
+
config_files=[os.getenv("CONTREE_MCP_CONFIG", "~/.config/contree/mcp.ini")],
|
|
13
|
+
auto_env_var_prefix="CONTREE_MCP_",
|
|
14
|
+
)
|
|
15
|
+
parser.parse_args()
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(level=parser.log_level, format="[%(levelname)s] %(message)s", stream=sys.stderr)
|
|
18
|
+
try:
|
|
19
|
+
asyncio.run(amain(parser))
|
|
20
|
+
except KeyboardInterrupt:
|
|
21
|
+
logging.info("Gracefully exited on keyboard interrupt")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
contree_mcp/app.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
## MANDATORY WORKFLOW
|
|
3
|
+
|
|
4
|
+
Every task MUST follow this sequence:
|
|
5
|
+
|
|
6
|
+
### Step 1: CHECK for Prepared Environment
|
|
7
|
+
```
|
|
8
|
+
list_images(tag_prefix="common/")
|
|
9
|
+
```
|
|
10
|
+
Search for existing prepared images before creating new ones.
|
|
11
|
+
|
|
12
|
+
### Step 2: PREPARE Environment (if not found)
|
|
13
|
+
```
|
|
14
|
+
import_image(registry_url="docker://python:3.11-slim")
|
|
15
|
+
run(command="pip install ...", image="<uuid>", disposable=false)
|
|
16
|
+
set_tag(image_uuid="<result>", tag="common/python-ml/python:3.11-slim")
|
|
17
|
+
```
|
|
18
|
+
Build and TAG prepared images for reuse. CRITICAL: Use `disposable=false` to save state.
|
|
19
|
+
|
|
20
|
+
### Step 3: EXECUTE Task
|
|
21
|
+
```
|
|
22
|
+
run(command="...", image="tag:common/python-ml/python:3.11-slim")
|
|
23
|
+
```
|
|
24
|
+
Use tagged image for efficient execution.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Tagging Convention
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
{scope}/{purpose}/{base}:{tag}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Component | Description | Examples |
|
|
35
|
+
|-----------|-------------|----------|
|
|
36
|
+
| `{scope}` | `common` or project name | `common`, `myproject` |
|
|
37
|
+
| `{purpose}` | What was added/configured | `rust-toolchain`, `python-ml`, `web-deps` |
|
|
38
|
+
| `{base}:{tag}` | Original base image | `ubuntu:noble`, `python:3.11-slim` |
|
|
39
|
+
|
|
40
|
+
**Examples:**
|
|
41
|
+
- `common/rust-toolchain/ubuntu:noble` - Ubuntu with Rust
|
|
42
|
+
- `common/python-ml/python:3.11-slim` - Python with ML libraries
|
|
43
|
+
- `myproject/dev-env/python:3.11-slim` - Project-specific setup
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## NEVER DO THESE
|
|
48
|
+
|
|
49
|
+
| Anti-pattern | Consequence | Correct approach |
|
|
50
|
+
|--------------|-------------|------------------|
|
|
51
|
+
| Import without checking | Wastes 10s-30min on duplicate imports | `list_images(tag_prefix="...")` first |
|
|
52
|
+
| Skip tagging prepared images | Rebuilds from scratch next time | `set_tag()` after installing deps |
|
|
53
|
+
| Chain commands in one run | Cannot rollback individual steps | One step per `run` |
|
|
54
|
+
| Use `disposable=true` for installs | Loses all installed packages | `disposable=false` for setup |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Tool Cost Reference
|
|
59
|
+
|
|
60
|
+
| Tool | Cost | Notes |
|
|
61
|
+
|------|------|-------|
|
|
62
|
+
| `run` | VM (~2-5s) | Command execution |
|
|
63
|
+
| `import_image` | VM (~10-30s) | Image pull from registry |
|
|
64
|
+
| `rsync`, `upload`, `download` | Free | File transfer operations |
|
|
65
|
+
| `list_images`, `get_image`, `set_tag` | Free | Image metadata |
|
|
66
|
+
| `list_files`, `read_file` | Free | Inspect container filesystem |
|
|
67
|
+
| `get_operation`, `list_operations`, `wait_operations`, `cancel_operation` | Free | Async management |
|
|
68
|
+
| `get_guide` | Free | Access documentation |
|
|
69
|
+
|
|
70
|
+
**Cost Optimization:** Use `list_files`/`read_file` instead of `run("ls")`/`run("cat")`.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Image Inspection (Free)
|
|
75
|
+
|
|
76
|
+
Inspect container filesystem without VM cost:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
list_files(image="<uuid>", path="/etc") # List directory contents
|
|
80
|
+
read_file(image="<uuid>", path="/etc/passwd") # Read file contents
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Prefer these over `run("ls ...")`/`run("cat ...")` for simple inspection.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Guides
|
|
88
|
+
|
|
89
|
+
Access documentation via resource URI or tool:
|
|
90
|
+
|
|
91
|
+
| Guide | Resource URI | Tool Alternative |
|
|
92
|
+
|-------|--------------|------------------|
|
|
93
|
+
| Workflow patterns | `contree://guide/workflow` | `get_guide(section="workflow")` |
|
|
94
|
+
| Async execution | `contree://guide/async` | `get_guide(section="async")` |
|
|
95
|
+
| Tagging convention | `contree://guide/tagging` | `get_guide(section="tagging")` |
|
|
96
|
+
| Tool reference | `contree://guide/reference` | `get_guide(section="reference")` |
|
|
97
|
+
| Error handling | `contree://guide/errors` | `get_guide(section="errors")` |
|
|
98
|
+
|
|
99
|
+
Use resources if supported by your agent runtime, otherwise use `get_guide()`.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
import re
|
|
103
|
+
from collections.abc import Awaitable, Callable
|
|
104
|
+
from textwrap import dedent
|
|
105
|
+
from typing import Any
|
|
106
|
+
|
|
107
|
+
from mcp.server import FastMCP
|
|
108
|
+
from mcp.server.fastmcp.prompts import Prompt
|
|
109
|
+
from mcp.server.fastmcp.resources import ResourceTemplate
|
|
110
|
+
from pydantic import AnyUrl
|
|
111
|
+
|
|
112
|
+
from contree_mcp import prompts
|
|
113
|
+
|
|
114
|
+
from . import resources, tools
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PathResourceTemplate(ResourceTemplate):
|
|
118
|
+
"""Resource template that supports path parameters with slashes.
|
|
119
|
+
|
|
120
|
+
FastMCP's default ResourceTemplate uses [^/]+ for parameters, which doesn't
|
|
121
|
+
match paths containing slashes. This subclass overrides the matches() method
|
|
122
|
+
to use .+ for the last parameter named 'path', allowing paths like 'etc/passwd'.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def matches(self, uri: str) -> dict[str, Any] | None:
|
|
126
|
+
"""Check if URI matches template and extract parameters.
|
|
127
|
+
|
|
128
|
+
Uses .+ for the last {path} parameter to capture paths with slashes.
|
|
129
|
+
"""
|
|
130
|
+
# Build regex pattern, using .+ for the last {path} parameter
|
|
131
|
+
pattern = self.uri_template
|
|
132
|
+
|
|
133
|
+
# Find all parameter names
|
|
134
|
+
param_names = re.findall(r"\{(\w+)\}", pattern)
|
|
135
|
+
|
|
136
|
+
for i, param in enumerate(param_names):
|
|
137
|
+
is_last = i == len(param_names) - 1
|
|
138
|
+
is_path = param == "path"
|
|
139
|
+
|
|
140
|
+
if is_last and is_path:
|
|
141
|
+
# Last path parameter: match anything including slashes
|
|
142
|
+
pattern = pattern.replace(f"{{{param}}}", f"(?P<{param}>.+)")
|
|
143
|
+
else:
|
|
144
|
+
# Regular parameter: don't match slashes
|
|
145
|
+
pattern = pattern.replace(f"{{{param}}}", f"(?P<{param}>[^/]+)")
|
|
146
|
+
|
|
147
|
+
match = re.match(f"^{pattern}$", uri)
|
|
148
|
+
if match:
|
|
149
|
+
return match.groupdict()
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def register_resource_template(mcp: FastMCP, url: str, resource_template_func: Callable[..., Awaitable[Any]]) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Register a resource template with the MCP app.
|
|
156
|
+
|
|
157
|
+
Uses PathResourceTemplate for URLs containing {path} to support
|
|
158
|
+
paths with slashes (e.g., etc/passwd) without URL encoding.
|
|
159
|
+
"""
|
|
160
|
+
description = dedent(resource_template_func.__doc__ or "") or ""
|
|
161
|
+
|
|
162
|
+
# Use PathResourceTemplate for URLs with {path} parameter
|
|
163
|
+
if "{path}" in url:
|
|
164
|
+
template = PathResourceTemplate.from_function(
|
|
165
|
+
resource_template_func,
|
|
166
|
+
uri_template=url,
|
|
167
|
+
description=description,
|
|
168
|
+
)
|
|
169
|
+
# Directly add to resource manager's templates dict
|
|
170
|
+
mcp._resource_manager._templates[url] = template
|
|
171
|
+
else:
|
|
172
|
+
# Use standard FastMCP registration
|
|
173
|
+
decorator = mcp.resource(url, description=description)
|
|
174
|
+
decorator(resource_template_func)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def register_tool(mcp: FastMCP, tool_func: Callable[..., Awaitable[Any]], **kwargs: Any) -> None:
|
|
178
|
+
mcp.add_tool(tool_func, description=dedent(tool_func.__doc__ or "") or "", **kwargs)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def create_mcp_app(**kwargs: Any) -> FastMCP:
|
|
182
|
+
mcp = FastMCP(
|
|
183
|
+
name="contree-mcp",
|
|
184
|
+
instructions=dedent(__doc__).strip(),
|
|
185
|
+
streamable_http_path="/mcp",
|
|
186
|
+
json_response=True,
|
|
187
|
+
**kwargs,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
register_tool(mcp, tools.list_images)
|
|
191
|
+
register_tool(mcp, tools.registry_token_obtain)
|
|
192
|
+
register_tool(mcp, tools.registry_auth)
|
|
193
|
+
register_tool(mcp, tools.import_image)
|
|
194
|
+
register_tool(mcp, tools.get_image)
|
|
195
|
+
register_tool(mcp, tools.set_tag)
|
|
196
|
+
register_tool(mcp, tools.run)
|
|
197
|
+
register_tool(mcp, tools.rsync)
|
|
198
|
+
register_tool(mcp, tools.upload)
|
|
199
|
+
register_tool(mcp, tools.download)
|
|
200
|
+
register_tool(mcp, tools.get_operation)
|
|
201
|
+
register_tool(mcp, tools.list_operations)
|
|
202
|
+
register_tool(mcp, tools.wait_operations)
|
|
203
|
+
register_tool(mcp, tools.cancel_operation)
|
|
204
|
+
|
|
205
|
+
# some agents can not use resources, so we expose these as tools too
|
|
206
|
+
register_tool(mcp, tools.list_files)
|
|
207
|
+
register_tool(mcp, tools.read_file)
|
|
208
|
+
register_tool(mcp, tools.get_guide)
|
|
209
|
+
|
|
210
|
+
mcp.add_prompt(Prompt.from_function(prompts.prepare_environment, name="prepare-environment"))
|
|
211
|
+
mcp.add_prompt(Prompt.from_function(prompts.run_python, name="run-python"))
|
|
212
|
+
mcp.add_prompt(Prompt.from_function(prompts.run_shell, name="run-shell"))
|
|
213
|
+
mcp.add_prompt(Prompt.from_function(prompts.sync_and_run, name="sync-and-run"))
|
|
214
|
+
mcp.add_prompt(Prompt.from_function(prompts.install_packages, name="install-packages"))
|
|
215
|
+
mcp.add_prompt(Prompt.from_function(prompts.parallel_tasks, name="parallel-tasks"))
|
|
216
|
+
mcp.add_prompt(Prompt.from_function(prompts.build_project, name="build-project"))
|
|
217
|
+
mcp.add_prompt(Prompt.from_function(prompts.debug_failure, name="debug-failure"))
|
|
218
|
+
mcp.add_prompt(Prompt.from_function(prompts.inspect_image, name="inspect-image"))
|
|
219
|
+
mcp.add_prompt(Prompt.from_function(prompts.multi_stage_build, name="multi-stage-build"))
|
|
220
|
+
|
|
221
|
+
register_resource_template(mcp, "contree://image/{image}/read/{path}", resources.read_file)
|
|
222
|
+
register_resource_template(mcp, "contree://image/{image}/ls/{path}", resources.image_ls)
|
|
223
|
+
register_resource_template(mcp, "contree://image/{image}/lineage", resources.image_lineage)
|
|
224
|
+
register_resource_template(mcp, "contree://operations/instance/{operation_id}", resources.instance_operation)
|
|
225
|
+
register_resource_template(mcp, "contree://operations/import/{operation_id}", resources.import_operation)
|
|
226
|
+
|
|
227
|
+
# Register guide sections as static resources for discovery
|
|
228
|
+
for section, content in resources.SECTIONS.items():
|
|
229
|
+
mcp.add_resource(
|
|
230
|
+
resources.StaticResource(
|
|
231
|
+
content,
|
|
232
|
+
uri=AnyUrl(f"contree://guide/{section}"),
|
|
233
|
+
name=section,
|
|
234
|
+
title=f"Contree Guide: {section.replace('-', ' ').title()}",
|
|
235
|
+
description=f"Guide section on {section.replace('-', ' ')}",
|
|
236
|
+
mime_type="text/markdown",
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return mcp
|
contree_mcp/arguments.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import argclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ServerMode(str, Enum):
|
|
8
|
+
STDIO = "stdio"
|
|
9
|
+
HTTP = "http"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HTTPGroup(argclass.Group):
|
|
13
|
+
listen: str = argclass.Argument(default="127.0.0.1")
|
|
14
|
+
port: int = argclass.Argument(default=9452)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Cache(argclass.Group):
|
|
18
|
+
files: Path = Path("~") / ".cache" / "contree_mcp" / "files.db"
|
|
19
|
+
general: Path = Path("~") / ".cache" / "contree_mcp" / "cache.db"
|
|
20
|
+
prune_days: int = argclass.Argument(
|
|
21
|
+
default=60,
|
|
22
|
+
help="Delete cached entries older than this many days",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Parser(argclass.Parser):
|
|
27
|
+
url: str = argclass.Argument(default="https://contree.dev", help="Contree API base URL")
|
|
28
|
+
token: str = argclass.Argument(secret=True, help="Contree API authentication token", required=True)
|
|
29
|
+
mode: ServerMode = argclass.EnumArgument(
|
|
30
|
+
ServerMode, default=ServerMode.STDIO, lowercase=True, help="Server transport mode"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
log_level: int = argclass.LogLevel
|
|
34
|
+
http: HTTPGroup = HTTPGroup()
|
|
35
|
+
cache: Cache = Cache()
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import webbrowser
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from httpx import URL
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
# Known registries and their PAT creation URLs
|
|
16
|
+
KNOWN_REGISTRIES: Mapping[str, URL] = MappingProxyType(
|
|
17
|
+
{
|
|
18
|
+
"docker.io": URL("https://app.docker.com/settings/personal-access-tokens"),
|
|
19
|
+
"ghcr.io": URL("https://github.com/settings/tokens?type=beta"),
|
|
20
|
+
"registry.gitlab.com": URL("https://gitlab.com/-/user_settings/personal_access_tokens"),
|
|
21
|
+
"gcr.io": URL("https://console.cloud.google.com/apis/credentials"),
|
|
22
|
+
"us.gcr.io": URL("https://console.cloud.google.com/apis/credentials"),
|
|
23
|
+
"eu.gcr.io": URL("https://console.cloud.google.com/apis/credentials"),
|
|
24
|
+
"asia.gcr.io": URL("https://console.cloud.google.com/apis/credentials"),
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Registry hostname aliases (some registries have different API hostnames)
|
|
29
|
+
REGISTRY_API_HOSTS: Mapping[str, str] = MappingProxyType({"docker.io": "registry-1.docker.io"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AuthEndpoint:
|
|
34
|
+
"""Authentication endpoint discovered from registry /v2/ response."""
|
|
35
|
+
|
|
36
|
+
realm: str # Token endpoint URL
|
|
37
|
+
service: str # Service name for token request
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RegistryAuth:
|
|
42
|
+
"""OCI registry authentication handler.
|
|
43
|
+
|
|
44
|
+
Provides methods for token discovery, validation, and exchange
|
|
45
|
+
using the OCI distribution spec.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
registry: str
|
|
49
|
+
_endpoint: AuthEndpoint | None = field(default=None, repr=False)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_url(cls, registry_url: str) -> RegistryAuth:
|
|
53
|
+
"""Create RegistryAuth from an image URL.
|
|
54
|
+
|
|
55
|
+
Note: oci:// scheme is transparently converted to docker:// (same protocol)
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
- "docker://ghcr.io/org/image:tag" -> RegistryAuth(registry="ghcr.io")
|
|
59
|
+
- "oci://registry.gitlab.com/org/img" -> RegistryAuth(registry="registry.gitlab.com")
|
|
60
|
+
- "myorg/myimage:latest" -> RegistryAuth(registry="docker.io")
|
|
61
|
+
- "alpine" -> RegistryAuth(registry="docker.io")
|
|
62
|
+
"""
|
|
63
|
+
# Normalize oci:// to docker://
|
|
64
|
+
if registry_url.startswith("oci://"):
|
|
65
|
+
registry_url = "docker://" + registry_url[6:]
|
|
66
|
+
|
|
67
|
+
# If no scheme, it's a bare image name -> docker.io
|
|
68
|
+
if "://" not in registry_url:
|
|
69
|
+
return cls(registry="docker.io")
|
|
70
|
+
|
|
71
|
+
# Use httpx.URL for parsing
|
|
72
|
+
url = httpx.URL(registry_url)
|
|
73
|
+
return cls(registry=url.host or "docker.io")
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def api_host(self) -> str:
|
|
77
|
+
"""Get the API hostname for this registry.
|
|
78
|
+
|
|
79
|
+
Some registries (like docker.io) use a different hostname for API calls.
|
|
80
|
+
"""
|
|
81
|
+
return REGISTRY_API_HOSTS.get(self.registry, self.registry)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def pat_url(self) -> str | None:
|
|
85
|
+
"""Get PAT creation URL for this registry.
|
|
86
|
+
|
|
87
|
+
Returns None if registry is not in the known list.
|
|
88
|
+
"""
|
|
89
|
+
url = KNOWN_REGISTRIES.get(self.registry)
|
|
90
|
+
return str(url) if url is not None else None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_known(self) -> bool:
|
|
94
|
+
"""Check if this registry is in the known list."""
|
|
95
|
+
return self.registry in KNOWN_REGISTRIES
|
|
96
|
+
|
|
97
|
+
def open_pat_page(self) -> str | None:
|
|
98
|
+
"""Open browser to PAT creation page.
|
|
99
|
+
|
|
100
|
+
Returns the URL if opened, None if registry is unknown.
|
|
101
|
+
"""
|
|
102
|
+
url = self.pat_url
|
|
103
|
+
if url:
|
|
104
|
+
webbrowser.open(url)
|
|
105
|
+
return url
|
|
106
|
+
|
|
107
|
+
async def discover_endpoint(self) -> AuthEndpoint:
|
|
108
|
+
"""Discover token endpoint from registry's /v2/ response.
|
|
109
|
+
|
|
110
|
+
Calls the registry's /v2/ endpoint and parses the Www-Authenticate header
|
|
111
|
+
to find the token realm and service. Caches the result for reuse.
|
|
112
|
+
"""
|
|
113
|
+
if self._endpoint is not None:
|
|
114
|
+
return self._endpoint
|
|
115
|
+
|
|
116
|
+
url = f"https://{self.api_host}/v2/"
|
|
117
|
+
|
|
118
|
+
async with httpx.AsyncClient() as client:
|
|
119
|
+
response = await client.get(url, follow_redirects=True)
|
|
120
|
+
|
|
121
|
+
if response.status_code == 401:
|
|
122
|
+
www_auth = response.headers.get("Www-Authenticate", "")
|
|
123
|
+
endpoint = self._parse_www_authenticate(www_auth)
|
|
124
|
+
if endpoint:
|
|
125
|
+
self._endpoint = endpoint
|
|
126
|
+
return endpoint
|
|
127
|
+
|
|
128
|
+
# If we get 200, try catalog request to get auth info
|
|
129
|
+
if response.status_code == 200:
|
|
130
|
+
catalog_url = f"https://{self.api_host}/v2/_catalog"
|
|
131
|
+
catalog_response = await client.get(catalog_url, follow_redirects=True)
|
|
132
|
+
if catalog_response.status_code == 401:
|
|
133
|
+
www_auth = catalog_response.headers.get("Www-Authenticate", "")
|
|
134
|
+
endpoint = self._parse_www_authenticate(www_auth)
|
|
135
|
+
if endpoint:
|
|
136
|
+
self._endpoint = endpoint
|
|
137
|
+
return endpoint
|
|
138
|
+
|
|
139
|
+
raise ValueError(f"Could not discover auth endpoint for registry {self.registry}")
|
|
140
|
+
|
|
141
|
+
async def validate_token(self, username: str, token: str) -> bool:
|
|
142
|
+
"""Validate token by requesting a token from the auth endpoint.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
username: Registry username
|
|
146
|
+
token: Personal Access Token
|
|
147
|
+
|
|
148
|
+
Returns True if the token is valid and can be used for authentication.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
endpoint = await self.discover_endpoint()
|
|
152
|
+
except ValueError:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
async with httpx.AsyncClient() as client:
|
|
156
|
+
response = await client.get(
|
|
157
|
+
endpoint.realm,
|
|
158
|
+
params={"service": endpoint.service},
|
|
159
|
+
auth=httpx.BasicAuth(username, token),
|
|
160
|
+
)
|
|
161
|
+
return response.status_code == 200
|
|
162
|
+
|
|
163
|
+
async def get_bearer_token(self, username: str, token: str, scope: str) -> str | None:
|
|
164
|
+
"""Exchange stored PAT for a scoped registry bearer token.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
username: Registry username
|
|
168
|
+
token: Personal Access Token
|
|
169
|
+
scope: Scope string (e.g., "repository:myorg/myimage:pull")
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Bearer token for the specified scope, or None if authentication failed
|
|
173
|
+
"""
|
|
174
|
+
endpoint = await self.discover_endpoint()
|
|
175
|
+
|
|
176
|
+
params = {
|
|
177
|
+
"service": endpoint.service,
|
|
178
|
+
"scope": scope,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async with httpx.AsyncClient() as client:
|
|
182
|
+
response = await client.get(
|
|
183
|
+
endpoint.realm,
|
|
184
|
+
params=params,
|
|
185
|
+
auth=httpx.BasicAuth(username, token),
|
|
186
|
+
)
|
|
187
|
+
if response.status_code != 200:
|
|
188
|
+
return None
|
|
189
|
+
return cast(str | None, response.json().get("token"))
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _parse_www_authenticate(header: str) -> AuthEndpoint | None:
|
|
193
|
+
"""Parse Www-Authenticate header to extract realm and service.
|
|
194
|
+
|
|
195
|
+
Example header:
|
|
196
|
+
Bearer realm="https://auth.docker.io/token",service="registry.docker.io"
|
|
197
|
+
"""
|
|
198
|
+
if not header.startswith("Bearer "):
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
# Extract realm
|
|
202
|
+
realm_match = re.search(r'realm="([^"]+)"', header)
|
|
203
|
+
if not realm_match:
|
|
204
|
+
return None
|
|
205
|
+
realm = realm_match.group(1)
|
|
206
|
+
|
|
207
|
+
# Extract service
|
|
208
|
+
service_match = re.search(r'service="([^"]+)"', header)
|
|
209
|
+
service = service_match.group(1) if service_match else ""
|
|
210
|
+
|
|
211
|
+
return AuthEndpoint(realm=realm, service=service)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class RegistryToken(BaseModel):
|
|
215
|
+
"""Stored registry authentication token."""
|
|
216
|
+
|
|
217
|
+
registry: str
|
|
218
|
+
username: str
|
|
219
|
+
token: str
|
|
220
|
+
scopes: list[str] = Field(default_factory=lambda: ["pull"])
|
|
221
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def normalize_registry_url(registry_url: str) -> str:
|
|
225
|
+
"""Normalize registry URL to docker:// scheme.
|
|
226
|
+
|
|
227
|
+
oci:// is transparently converted to docker:// as they use the same protocol.
|
|
228
|
+
"""
|
|
229
|
+
if registry_url.startswith("oci://"):
|
|
230
|
+
registry_url = "docker://" + registry_url[6:]
|
|
231
|
+
|
|
232
|
+
# Add docker:// if no scheme
|
|
233
|
+
if "://" not in registry_url:
|
|
234
|
+
registry_url = f"docker://docker.io/{registry_url}"
|
|
235
|
+
|
|
236
|
+
return registry_url
|