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