Skip to content

Commit 7b917db

Browse files
committed
refactor: merge Docker scripts into unified Python implementation
- Merged docker.sh and docker_image.py into single docker.py script - Unified tag generation, build, and push operations - Updated all references in workflows and Makefiles - Added comprehensive tests for the new unified script - Removed legacy bash and separate Python scripts - Maintains backward compatibility with same functionality
1 parent b57b966 commit 7b917db

File tree

8 files changed

+418
-444
lines changed

8 files changed

+418
-444
lines changed

.github/actions/create-release/action.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,8 @@ runs:
6262
AWS_ACCOUNT_ID: ${{ env.AWS_ACCOUNT_ID }}
6363
AWS_DEFAULT_REGION: ${{ env.AWS_DEFAULT_REGION }}
6464
run: |
65-
echo "🐳 Building and pushing Docker image with scripts/docker.sh"
66-
chmod +x scripts/docker.sh
67-
./scripts/docker.sh push --version "$VERSION"
65+
echo "🐳 Building and pushing Docker image with scripts/docker.py"
66+
uv run python scripts/docker.py push --version "$VERSION"
6867
6968
- name: Create GitHub Release
7069
id: create-release

.github/workflows/pr.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ jobs:
4444

4545
- name: Test Docker build (no push)
4646
if: github.event_name == 'pull_request'
47+
env:
48+
VERSION: test-${{ github.sha }}
4749
run: |
4850
echo "🐳 Testing Docker build for PR (no push)..."
49-
chmod +x scripts/docker.sh
50-
./scripts/docker.sh build
51+
uv run python scripts/docker.py build --version "$VERSION"
5152
5253
- name: Debug GitHub context for dev-release
5354
run: |

make.deploy

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ release-zip: $(RELEASE_ZIP)
129129
# Docker Operations
130130
docker-build: check-docker-tools
131131
@echo "🐳 Building Docker image locally..."
132-
@./scripts/docker.sh build
132+
@uv run python scripts/docker.py build
133133
@echo "✅ Docker build completed"
134134

135135
docker-push: check-docker-tools
@@ -138,14 +138,14 @@ docker-push: check-docker-tools
138138
echo "❌ VERSION is required for docker-push. Use: make docker-push VERSION=1.2.3"; \
139139
exit 1; \
140140
fi
141-
@VERSION=$(PACKAGE_VERSION) ./scripts/docker.sh push --version $(PACKAGE_VERSION)
141+
@VERSION=$(PACKAGE_VERSION) uv run python scripts/docker.py push --version $(PACKAGE_VERSION)
142142
@echo "✅ Docker push completed"
143143

144144
docker-push-dev: check-docker-tools
145145
@echo "🐳 Building and pushing development Docker image..."
146146
@DEV_VERSION="$(PACKAGE_VERSION)-dev-$(shell date +%Y%m%d%H%M%S)"
147147
@echo "Using development version: $$DEV_VERSION"
148-
@VERSION=$$DEV_VERSION ./scripts/docker.sh push --version $$DEV_VERSION --no-latest
148+
@VERSION=$$DEV_VERSION uv run python scripts/docker.py push --version $$DEV_VERSION --no-latest
149149
@echo "✅ Development Docker push completed"
150150

151151
# Release Tagging Targets

scripts/docker.py

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
#!/usr/bin/env python3
2+
"""Unified Docker build and deployment script for Quilt MCP Server.
3+
4+
Combines Docker image tag generation with build and push operations.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import argparse
10+
import json
11+
import os
12+
import subprocess
13+
import sys
14+
from dataclasses import dataclass
15+
from pathlib import Path
16+
from typing import Iterable, Optional
17+
18+
19+
# Configuration
20+
DEFAULT_IMAGE_NAME = "quilt-mcp-server"
21+
DEFAULT_REGION = "us-east-1"
22+
LATEST_TAG = "latest"
23+
24+
25+
@dataclass(frozen=True)
26+
class ImageReference:
27+
"""Represents a fully-qualified Docker image reference."""
28+
29+
registry: str
30+
image: str
31+
tag: str
32+
33+
@property
34+
def uri(self) -> str:
35+
return f"{self.registry}/{self.image}:{self.tag}"
36+
37+
38+
class DockerManager:
39+
"""Manages Docker operations for Quilt MCP Server."""
40+
41+
def __init__(
42+
self,
43+
registry: Optional[str] = None,
44+
image_name: str = DEFAULT_IMAGE_NAME,
45+
region: str = DEFAULT_REGION,
46+
dry_run: bool = False,
47+
):
48+
self.image_name = image_name
49+
self.region = region
50+
self.dry_run = dry_run
51+
self.registry = self._get_registry(registry)
52+
self.project_root = Path(__file__).parent.parent
53+
54+
def _get_registry(self, registry: Optional[str]) -> str:
55+
"""Determine ECR registry URL from various sources."""
56+
# Priority: explicit parameter > ECR_REGISTRY env > construct from AWS_ACCOUNT_ID
57+
if registry:
58+
return registry
59+
60+
if ecr_registry := os.getenv("ECR_REGISTRY"):
61+
return ecr_registry
62+
63+
if aws_account_id := os.getenv("AWS_ACCOUNT_ID"):
64+
region = os.getenv("AWS_DEFAULT_REGION", self.region)
65+
return f"{aws_account_id}.dkr.ecr.{region}.amazonaws.com"
66+
67+
# For local builds, use a default local registry
68+
return "localhost:5000"
69+
70+
def _run_command(self, cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
71+
"""Execute a command with optional dry-run mode."""
72+
if self.dry_run:
73+
print(f"DRY RUN: Would execute: {' '.join(cmd)}", file=sys.stderr)
74+
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
75+
76+
print(f"INFO: Executing: {' '.join(cmd)}", file=sys.stderr)
77+
return subprocess.run(cmd, check=check, capture_output=True, text=True)
78+
79+
def _check_docker(self) -> bool:
80+
"""Validate Docker is available and running."""
81+
try:
82+
result = self._run_command(["docker", "info"], check=False)
83+
if result.returncode != 0:
84+
print("ERROR: Docker daemon is not running or not accessible", file=sys.stderr)
85+
return False
86+
return True
87+
except FileNotFoundError:
88+
print("ERROR: Docker is not installed or not in PATH", file=sys.stderr)
89+
return False
90+
91+
def generate_tags(self, version: str, include_latest: bool = True) -> list[ImageReference]:
92+
"""Generate Docker image tags for a given version."""
93+
if not self.registry:
94+
raise ValueError("registry is required")
95+
if not version:
96+
raise ValueError("version is required")
97+
98+
tags = [ImageReference(registry=self.registry, image=self.image_name, tag=version)]
99+
100+
if include_latest:
101+
tags.append(ImageReference(registry=self.registry, image=self.image_name, tag=LATEST_TAG))
102+
103+
return tags
104+
105+
def build(self, tag: str) -> bool:
106+
"""Build Docker image with the specified tag."""
107+
print(f"INFO: Building Docker image: {tag}", file=sys.stderr)
108+
109+
os.chdir(self.project_root)
110+
result = self._run_command(["docker", "build", "--file", "Dockerfile", "--tag", tag, "."])
111+
112+
if result.returncode == 0:
113+
print(f"INFO: Successfully built: {tag}", file=sys.stderr)
114+
return True
115+
else:
116+
print(f"ERROR: Failed to build image: {result.stderr}", file=sys.stderr)
117+
return False
118+
119+
def tag(self, source: str, target: str) -> bool:
120+
"""Tag a Docker image."""
121+
print(f"INFO: Tagging image: {source} -> {target}", file=sys.stderr)
122+
123+
result = self._run_command(["docker", "tag", source, target])
124+
125+
if result.returncode == 0:
126+
return True
127+
else:
128+
print(f"ERROR: Failed to tag image: {result.stderr}", file=sys.stderr)
129+
return False
130+
131+
def push(self, tag: str) -> bool:
132+
"""Push Docker image to registry."""
133+
print(f"INFO: Pushing image: {tag}", file=sys.stderr)
134+
135+
result = self._run_command(["docker", "push", tag])
136+
137+
if result.returncode == 0:
138+
print(f"INFO: Successfully pushed: {tag}", file=sys.stderr)
139+
return True
140+
else:
141+
print(f"ERROR: Failed to push image: {result.stderr}", file=sys.stderr)
142+
return False
143+
144+
def build_and_push(self, version: str, include_latest: bool = True) -> bool:
145+
"""Build and push Docker image with all generated tags."""
146+
if not self._check_docker():
147+
return False
148+
149+
# Generate tags
150+
tags = self.generate_tags(version, include_latest)
151+
152+
print(f"INFO: Using registry: {self.registry}", file=sys.stderr)
153+
print(f"INFO: Generated {len(tags)} image tags:", file=sys.stderr)
154+
for ref in tags:
155+
print(f"INFO: - {ref.uri}", file=sys.stderr)
156+
157+
# Build with first tag
158+
primary_tag = tags[0].uri
159+
if not self.build(primary_tag):
160+
return False
161+
162+
# Tag with additional tags
163+
for ref in tags[1:]:
164+
if not self.tag(primary_tag, ref.uri):
165+
return False
166+
167+
# Push all tags
168+
for ref in tags:
169+
if not self.push(ref.uri):
170+
return False
171+
172+
print(f"INFO: Docker push completed successfully", file=sys.stderr)
173+
print(f"INFO: Pushed {len(tags)} tags to registry: {self.registry}", file=sys.stderr)
174+
return True
175+
176+
def build_local(self, version: str = "dev") -> bool:
177+
"""Build Docker image locally without pushing."""
178+
if not self._check_docker():
179+
return False
180+
181+
# For local builds, use simple tagging
182+
local_tag = f"{self.registry}/{self.image_name}:{version}"
183+
184+
print(f"INFO: Building Docker image locally", file=sys.stderr)
185+
if not self.build(local_tag):
186+
return False
187+
188+
print(f"INFO: Local build completed: {local_tag}", file=sys.stderr)
189+
return True
190+
191+
192+
def parse_args(argv: Iterable[str]) -> argparse.Namespace:
193+
"""Parse command line arguments."""
194+
parser = argparse.ArgumentParser(
195+
description="Docker build and deployment for Quilt MCP Server",
196+
formatter_class=argparse.RawDescriptionHelpFormatter,
197+
epilog="""
198+
EXAMPLES:
199+
# Generate tags for a version
200+
%(prog)s tags --version 1.2.3
201+
202+
# Build locally for testing
203+
%(prog)s build
204+
205+
# Build and push to ECR
206+
%(prog)s push --version 1.2.3
207+
208+
# Dry run to see what would happen
209+
%(prog)s push --version 1.2.3 --dry-run
210+
211+
ENVIRONMENT VARIABLES:
212+
ECR_REGISTRY ECR registry URL
213+
AWS_ACCOUNT_ID AWS account ID (used to construct registry)
214+
AWS_DEFAULT_REGION AWS region (default: us-east-1)
215+
VERSION Version tag (can override --version)
216+
""",
217+
)
218+
219+
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
220+
221+
# Tags command (replaces docker_image.py functionality)
222+
tags_parser = subparsers.add_parser("tags", help="Generate Docker image tags")
223+
tags_parser.add_argument("--version", required=True, help="Version tag for the image")
224+
tags_parser.add_argument("--registry", help="ECR registry URL")
225+
tags_parser.add_argument("--image", default=DEFAULT_IMAGE_NAME, help="Image name")
226+
tags_parser.add_argument("--output", choices=["text", "json"], default="text", help="Output format")
227+
tags_parser.add_argument("--no-latest", action="store_true", help="Don't include latest tag")
228+
229+
# Build command
230+
build_parser = subparsers.add_parser("build", help="Build Docker image locally")
231+
build_parser.add_argument("--version", default="dev", help="Version tag (default: dev)")
232+
build_parser.add_argument("--registry", help="Registry URL")
233+
build_parser.add_argument("--image", default=DEFAULT_IMAGE_NAME, help="Image name")
234+
235+
# Push command
236+
push_parser = subparsers.add_parser("push", help="Build and push Docker image to registry")
237+
push_parser.add_argument("--version", required=True, help="Version tag for the image")
238+
push_parser.add_argument("--registry", help="ECR registry URL")
239+
push_parser.add_argument("--image", default=DEFAULT_IMAGE_NAME, help="Image name")
240+
push_parser.add_argument("--region", default=DEFAULT_REGION, help="AWS region")
241+
push_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
242+
push_parser.add_argument("--no-latest", action="store_true", help="Don't tag as latest")
243+
244+
return parser.parse_args(list(argv))
245+
246+
247+
def cmd_tags(args: argparse.Namespace) -> int:
248+
"""Generate and display Docker image tags."""
249+
try:
250+
manager = DockerManager(registry=args.registry, image_name=args.image)
251+
references = manager.generate_tags(args.version, include_latest=not args.no_latest)
252+
253+
if args.output == "json":
254+
payload = {
255+
"registry": manager.registry,
256+
"image": args.image,
257+
"tags": [ref.tag for ref in references],
258+
"uris": [ref.uri for ref in references],
259+
}
260+
print(json.dumps(payload))
261+
else:
262+
for ref in references:
263+
print(ref.uri)
264+
265+
return 0
266+
except ValueError as exc:
267+
print(f"ERROR: {exc}", file=sys.stderr)
268+
return 1
269+
270+
271+
def cmd_build(args: argparse.Namespace) -> int:
272+
"""Build Docker image locally."""
273+
# Allow VERSION env var to override
274+
version = os.getenv("VERSION", args.version)
275+
276+
manager = DockerManager(registry=args.registry, image_name=args.image)
277+
success = manager.build_local(version)
278+
return 0 if success else 1
279+
280+
281+
def cmd_push(args: argparse.Namespace) -> int:
282+
"""Build and push Docker image to registry."""
283+
# Allow VERSION env var to override
284+
version = os.getenv("VERSION", args.version)
285+
286+
manager = DockerManager(
287+
registry=args.registry,
288+
image_name=args.image,
289+
region=args.region,
290+
dry_run=args.dry_run,
291+
)
292+
success = manager.build_and_push(version, include_latest=not args.no_latest)
293+
return 0 if success else 1
294+
295+
296+
def main(argv: Iterable[str] | None = None) -> int:
297+
"""Main entry point."""
298+
args = parse_args(argv or sys.argv[1:])
299+
300+
if not args.command:
301+
print("ERROR: Command is required. Use --help for usage information.", file=sys.stderr)
302+
return 1
303+
304+
if args.command == "tags":
305+
return cmd_tags(args)
306+
elif args.command == "build":
307+
return cmd_build(args)
308+
elif args.command == "push":
309+
return cmd_push(args)
310+
else:
311+
print(f"ERROR: Unknown command: {args.command}", file=sys.stderr)
312+
return 1
313+
314+
315+
if __name__ == "__main__":
316+
sys.exit(main())

0 commit comments

Comments
 (0)