docker-stack 0.1.1__tar.gz → 0.2.1__tar.gz

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.
Files changed (23) hide show
  1. {docker-stack-0.1.1 → docker-stack-0.2.1}/PKG-INFO +1 -1
  2. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/__init__.py +2 -1
  3. docker-stack-0.2.1/docker_stack/cli.py +238 -0
  4. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/compose.py +1 -1
  5. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/docker_objects.py +6 -4
  6. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/envsubst.py +37 -4
  7. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/helpers.py +17 -5
  8. docker-stack-0.2.1/docker_stack/registry.py +239 -0
  9. docker-stack-0.2.1/docker_stack/url_parser.py +171 -0
  10. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/PKG-INFO +1 -1
  11. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/SOURCES.txt +1 -0
  12. {docker-stack-0.1.1 → docker-stack-0.2.1}/setup.py +1 -1
  13. docker-stack-0.1.1/docker_stack/cli.py +0 -169
  14. docker-stack-0.1.1/docker_stack/registry.py +0 -150
  15. {docker-stack-0.1.1 → docker-stack-0.2.1}/README.md +0 -0
  16. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/envsubst_merge.py +0 -0
  17. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/merge_conf.py +0 -0
  18. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/dependency_links.txt +0 -0
  19. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/entry_points.txt +0 -0
  20. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/requires.txt +0 -0
  21. {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/top_level.txt +0 -0
  22. {docker-stack-0.1.1 → docker-stack-0.2.1}/pyproject.toml +0 -0
  23. {docker-stack-0.1.1 → docker-stack-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: docker-stack
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesuidp/docker-stack
6
6
  Author: Sudip Bhattarai
@@ -1,4 +1,5 @@
1
- from .envsubst import envsubst
1
+
2
+ from .envsubst import envsubst, envsubst_load_file
2
3
  from .compose import read_compose_file
3
4
  from .docker_objects import DockerConfig, DockerSecret
4
5
  from .cli import main
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import List
6
+ import os
7
+ import yaml
8
+ import json
9
+ from docker_stack.docker_objects import DockerConfig, DockerObjectManager, DockerSecret
10
+ from docker_stack.helpers import Command
11
+ from docker_stack.registry import DockerRegistry
12
+ from .envsubst import envsubst, envsubst_load_file
13
+
14
+ class Docker:
15
+ def __init__(self,registries:List[str]=[]):
16
+ self.stack = DockerStack(self)
17
+ self.config = DockerConfig()
18
+ self.secret = DockerSecret()
19
+ self.registry = DockerRegistry(registries)
20
+
21
+ @staticmethod
22
+ def load_env(env_file=".env"):
23
+ if Path(env_file).is_file():
24
+ with open(env_file) as f:
25
+ for line in f:
26
+ line = line.strip()
27
+ if line and not line.startswith("#"):
28
+ key, _, value = line.partition("=")
29
+ os.environ[key.strip()] = value.strip()
30
+
31
+ @staticmethod
32
+ def check_env(example_file=".env.example"):
33
+ if not Path(example_file).is_file():
34
+ return
35
+
36
+ unset_keys = []
37
+ with open(example_file) as f:
38
+ for line in f:
39
+ line = line.strip()
40
+ if line and not line.startswith("#"):
41
+ key = line.split("=")[0].strip()
42
+ if not os.environ.get(key):
43
+ unset_keys.append(key)
44
+
45
+ if unset_keys:
46
+ print("The following keys are not set in the environment:")
47
+ for key in unset_keys:
48
+ print(f"- {key}")
49
+ print("Exiting due to missing environment variables.")
50
+ sys.exit(2)
51
+
52
+ class DockerStack:
53
+ def __init__(self, docker: Docker):
54
+ self.docker = docker
55
+ self.commands: List[Command] = []
56
+
57
+ def read_compose_file(self,compose_file)->dict:
58
+ with open(compose_file) as f:
59
+ return self.decode_yaml(f.read())
60
+
61
+ def rendered_compose_file(self,compose_file,stack=None)->str:
62
+ with open(compose_file) as f:
63
+ template_content = f.read()
64
+ # Parse the YAML content
65
+ compose_data = self.decode_yaml(template_content)
66
+ if stack:
67
+ base_dir = os.path.dirname(os.path.abspath(compose_file))
68
+ if "configs" in compose_data:
69
+ compose_data["configs"] = self._process_x_content(compose_data["configs"], self.docker.config,base_dir=base_dir,stack=stack)
70
+ if "secrets" in compose_data:
71
+ compose_data["secrets"] = self._process_x_content(compose_data["secrets"], self.docker.secret,base_dir=base_dir,stack=stack)
72
+ return envsubst(yaml.dump(compose_data))
73
+
74
+ def decode_yaml(self,data:str)->dict:
75
+ return yaml.safe_load(data)
76
+
77
+ def render_compose_file(self, compose_file,stack=None):
78
+ """
79
+ Render the Docker Compose file with environment variables and create Docker configs/secrets.
80
+ """
81
+
82
+ # Convert the modified data back to YAML
83
+ rendered_content = self.rendered_compose_file(compose_file,stack)
84
+
85
+ # Write the rendered file
86
+ rendered_filename = Path(compose_file).with_name(
87
+ f"{Path(compose_file).stem}-rendered{Path(compose_file).suffix}"
88
+ )
89
+ with open(rendered_filename, "w") as f:
90
+ f.write(rendered_content)
91
+ with open(rendered_filename.as_posix()+".json","w") as f:
92
+ f.write(json.dumps(rendered_content,indent=2))
93
+ return (rendered_filename,rendered_content)
94
+
95
+
96
+
97
+ def _process_x_content(self, objects, manager:DockerObjectManager,base_dir="",stack=None):
98
+ """
99
+ Process configs or secrets with x-content keys.
100
+ Returns a tuple: (processed_objects, commands)
101
+ """
102
+ processed_objects = {}
103
+
104
+ def add_obj(name,data):
105
+ (object_name,command)=manager.create(name, data,stack=stack)
106
+ if not command.isNop():
107
+ self.commands.append(command)
108
+ processed_objects[name] = {"name": object_name,"external": True}
109
+ for name, details in objects.items():
110
+ if isinstance(details, dict) and "x-content" in details:
111
+ add_obj(name,details['x-content'])
112
+ elif isinstance(details, dict) and 'x-template' in details:
113
+ add_obj(name,envsubst(details['x-content'],os.environ))
114
+ elif isinstance(details, dict) and 'x-template-file' in details:
115
+ filename=os.path.join(base_dir,details['x-template-file'])
116
+ add_obj(name,envsubst_load_file(filename,os.environ))
117
+ elif isinstance(details, dict) and 'file' in details:
118
+ filename=os.path.join(base_dir,details['file'])
119
+ with open(filename) as file:
120
+ add_obj(name,file.read())
121
+ else:
122
+ processed_objects[name] = details
123
+ return processed_objects
124
+
125
+ def deploy(self, stack_name, compose_file, with_registry_auth=False):
126
+ rendered_filename, rendered_content = self.render_compose_file(compose_file,stack=stack_name)
127
+ _, cmd = self.docker.config.increment(stack_name, rendered_content, [f"mesudip.stack.name={stack_name}"],stack=stack_name)
128
+ if not cmd.isNop():
129
+ self.commands.append(cmd)
130
+ cmd = ["docker", "stack", "deploy", "-c", str(rendered_filename), stack_name]
131
+ if with_registry_auth:
132
+ cmd.insert(3, "--with-registry-auth")
133
+ self.commands.append(Command(cmd,give_console=True))
134
+
135
+ def push(self, compose_file):
136
+ compose_data = self.read_compose_file(compose_file)
137
+ for service_name, service_data in compose_data.get("services", {}).items():
138
+ if "build" in service_data:
139
+ image= envsubst(service_data['image'])
140
+ push_result = self.check_and_push_pull_image(image, 'push')
141
+ if push_result:
142
+ self.commands.append(push_result)
143
+ else:
144
+ # print("No need to push: Already exists")
145
+ pass
146
+
147
+ def build_and_push(self, compose_file: str, push: bool = False) -> None:
148
+ """
149
+ Build Docker images from a Compose file and optionally push them.
150
+
151
+ Args:
152
+ compose_file (str): Path to the Docker Compose file.
153
+ push (bool): Whether to push the built images. Defaults to False.
154
+ """
155
+ compose_data = self.read_compose_file(compose_file)
156
+ base_dir = os.path.dirname(os.path.abspath(compose_file))
157
+
158
+ for service_name, service_data in compose_data.get("services", {}).items():
159
+ if "build" in service_data:
160
+ build_config = service_data["build"]
161
+ image = envsubst(service_data['image'])
162
+
163
+ build_command = ["docker", "build", "-t", image]
164
+
165
+
166
+ for value in build_config.get('args', []):
167
+ build_command.extend(["--build-arg", envsubst(value)])
168
+
169
+ build_command.append(os.path.join(base_dir, build_config.get('context', '.')))
170
+ self.commands.append(Command(build_command))
171
+
172
+ if push:
173
+ push_result = self.check_and_push_pull_image(image, 'push')
174
+ if push_result:
175
+ self.commands.append(push_result)
176
+ else:
177
+ # print("No need to push: Already exists")
178
+ pass
179
+ def check_and_push_pull_image(self, image_name: str, action: str):
180
+ if self.docker.registry.check_image(image_name):
181
+ return None
182
+ if action == 'push':
183
+ cmd = self.docker.registry.push(image_name)
184
+ if cmd:
185
+ self.commands.append(cmd)
186
+
187
+
188
+ def main(args:List[str]=None):
189
+ parser = argparse.ArgumentParser(description="Deploy and manage Docker stacks.")
190
+ subparsers = parser.add_subparsers(dest="command", required=True)
191
+
192
+ # Build subcommand
193
+ build_parser = subparsers.add_parser("build", help="Build images using docker-compose")
194
+ build_parser.add_argument("compose_file", help="Path to the compose file")
195
+ build_parser.add_argument("--push", action="store_true", help="Use registry authentication")
196
+
197
+ # Push subcommand
198
+ push_parser = subparsers.add_parser("push", help="Push images to registry")
199
+ push_parser.add_argument("compose_file", help="Path to the compose file")
200
+
201
+ # Deploy subcommand
202
+ deploy_parser = subparsers.add_parser("deploy", help="Deploy stack using docker stack deploy")
203
+ deploy_parser.add_argument("stack_name", help="Name of the stack")
204
+ deploy_parser.add_argument("compose_file", help="Path to the compose file")
205
+ deploy_parser.add_argument("--with-registry-auth", action="store_true", help="Use registry authentication")
206
+
207
+ # Remove subcommand
208
+ rm_parser = subparsers.add_parser("rm", help="Remove a deployed stack")
209
+ rm_parser.add_argument("stack_name", help="Name of the stack")
210
+
211
+ parser.add_argument("-u", "--user", help="Registry credentials in format hostname:username:password", action="append", required=False, default=[])
212
+ parser.add_argument("-t", "--tag", help="Tag the current deployment for later checkout", required=False)
213
+ parser.add_argument("-ro",'-r',"--ro","--r", "--dry-run", action="store_true", help="Print commands, don't execute them", required=False)
214
+
215
+ args = parser.parse_args(args if args else sys.argv[1:])
216
+
217
+ docker = Docker(registries=args.user)
218
+ docker.load_env()
219
+
220
+ if args.command == "build":
221
+ docker.stack.build_and_push(args.compose_file,push=args.push)
222
+ elif args.command == "push":
223
+ docker.stack.push(args.compose_file)
224
+ elif args.command == "deploy":
225
+ docker.stack.deploy(args.stack_name, args.compose_file, args.with_registry_auth)
226
+ elif args.command == "rm":
227
+ docker.stack.rm(args.stack_name)
228
+
229
+
230
+ if args.ro:
231
+ print("Following commands were not executed:")
232
+ [print(" >> "+str(x)) for x in docker.stack.commands if x]
233
+ else:
234
+ [ x.execute() for x in docker.stack.commands]
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main([""])
@@ -1,5 +1,5 @@
1
1
  import yaml
2
-
2
+ import os
3
3
 
4
4
  def read_compose_file(compose_file_path):
5
5
  """
@@ -45,10 +45,11 @@ class DockerObjectManager:
45
45
  return False
46
46
 
47
47
  def create(
48
- self, object_name, object_content, labels: List[str] = []
48
+ self, object_name, object_content, labels: List[str] = [],stack=None
49
49
  ) -> Tuple[str, Command]:
50
50
  sha_hash = self.calculate_hash(object_name, object_content)
51
-
51
+ if stack :
52
+ labels=labels + ["com.docker.stack.namespace="+stack]
52
53
  # Check if any version of the object already exists by its label
53
54
  command = [
54
55
  "docker",
@@ -121,10 +122,11 @@ class DockerObjectManager:
121
122
  )
122
123
 
123
124
  def increment(
124
- self, object_name, object_content, labels: List[str] = []
125
+ self, object_name, object_content, labels: List[str] = [],stack=None
125
126
  ) -> Tuple[str, Command]:
126
127
  sha_hash = self.calculate_hash(object_name, object_content)
127
-
128
+ if stack :
129
+ labels=labels + ["com.docker.stack.namespace="+stack]
128
130
  # Check if any version of the object already exists by its label
129
131
  command = [
130
132
  "docker",
@@ -25,20 +25,49 @@ def envsubst(template_str, env=os.environ):
25
25
  # Regex for $VARIABLE without default
26
26
  pattern_without_default = re.compile(r"\$([a-zA-Z_][a-zA-Z0-9_]*)")
27
27
 
28
- def replace_with_default(match):
28
+ def print_error_line(template_str, match_span):
29
+ """Helper function to print the error context."""
30
+ lines = template_str.splitlines()
31
+
32
+ # Determine the start position and line
33
+ start_pos = match_span[0]
34
+ end_pos = match_span[1]
35
+
36
+ # Calculate line numbers based on character positions
37
+ char_count = 0
38
+ start_line = end_line = None
39
+ for i, line in enumerate(lines):
40
+ char_count += len(line) + 1 # +1 for the newline character
41
+ if start_line is None and char_count > start_pos:
42
+ start_line = i
43
+ if char_count >= end_pos:
44
+ end_line = i
45
+ break
46
+
47
+ # Display lines before, the error line, and after (with line numbers)
48
+ start = max(start_line - 1, 0)
49
+ end = min(end_line + 1, len(lines) - 1)
50
+
51
+ for i in range(start, end + 1):
52
+ print(f"{i + 1}: {lines[i]}",file=sys.stderr)
53
+
54
+ def replace_with_default(match: re.Match[str]):
29
55
  var = match.group(1)
30
56
  default_value = match.group(2) if match.group(2) is not None else None
31
57
  result = env.get(var, default_value)
32
58
  if result is None:
33
- print(f"Missing template variable with default: {var}", file=sys.stderr)
59
+ print_error_line(template_str, match.span())
60
+ print(f"ERROR :: Missing template variable with default: {var}", file=sys.stderr)
61
+
34
62
  exit(1)
35
63
  return result
36
64
 
37
- def replace_without_default(match):
65
+ def replace_without_default(match: re.Match[str]):
38
66
  var = match.group(1)
39
67
  result = env.get(var, None)
40
68
  if result is None:
41
- print(f"Missing template variable: {var}", file=sys.stderr)
69
+ print_error_line(template_str, match.span())
70
+ print(f"ERROR :: Missing template variable: {var}", file=sys.stderr)
42
71
  exit(1)
43
72
  return result
44
73
 
@@ -51,6 +80,10 @@ def envsubst(template_str, env=os.environ):
51
80
  return template_str
52
81
 
53
82
 
83
+ def envsubst_load_file(template_file,env=os.environ):
84
+ with open(template_file) as file:
85
+ return envsubst(file.read(),env)
86
+
54
87
  def main():
55
88
  if len(sys.argv) > 2:
56
89
  print("Usage: python envsubst.py [template_file]")
@@ -9,6 +9,7 @@ def run_cli_command(
9
9
  log: bool = True,
10
10
  shell: bool = False,
11
11
  interactive: bool = False,
12
+ cwd=None
12
13
  ) -> str:
13
14
  """
14
15
  Run a CLI command and return its output.
@@ -25,11 +26,11 @@ def run_cli_command(
25
26
  The stdout of the command as a string, or None if interactive mode is enabled.
26
27
  """
27
28
  if log:
28
- print("> " + " ".join(command))
29
+ print("> " + " ".join(command),flush=True)
29
30
 
30
31
  try:
31
32
  if interactive:
32
- result = subprocess.run(command, shell=shell)
33
+ result = subprocess.run(command, shell=shell,cwd=cwd)
33
34
  return None
34
35
  else:
35
36
  result = subprocess.run(
@@ -39,6 +40,7 @@ def run_cli_command(
39
40
  capture_output=True,
40
41
  check=raise_error,
41
42
  shell=shell,
43
+ cwd=cwd
42
44
  )
43
45
  return result.stdout.strip()
44
46
  except subprocess.CalledProcessError as e:
@@ -56,7 +58,8 @@ class Command:
56
58
  return self == Command.nop
57
59
 
58
60
  def __init__(
59
- self, command: List[str], stdin: Optional[str] = None, log: bool = True, id=None
61
+ self, command: List[str], stdin: Optional[str] = None, log: bool = True, id=None,give_console=False
62
+ ,cwd:str=None
60
63
  ):
61
64
  """
62
65
  Initialize a Command object.
@@ -70,6 +73,9 @@ class Command:
70
73
  self.stdin = stdin
71
74
  self.log = log
72
75
  self.id = id
76
+ self.give_console=give_console
77
+ self.cwd=cwd
78
+
73
79
 
74
80
  def execute(self, log: Optional[bool] = None) -> str:
75
81
  """
@@ -85,9 +91,15 @@ class Command:
85
91
  return
86
92
  # Use the provided log value if available, otherwise use the one from the constructor
87
93
  use_log = log if log is not None else self.log
94
+
88
95
  if not self.stdin:
89
- subprocess.run(self.command)
90
- return run_cli_command(self.command, stdin=self.stdin, log=use_log, shell=False)
96
+ if self.give_console:
97
+ print("Giving console")
98
+ process = subprocess.Popen(self.command, shell=True,cwd=self.cwd)
99
+ process.wait()
100
+ else:
101
+ subprocess.run(self.command)
102
+ return run_cli_command(self.command, stdin=self.stdin, log=use_log, shell=False,cwd=self.cwd)
91
103
 
92
104
  def __str__(self) -> str:
93
105
  """
@@ -0,0 +1,239 @@
1
+ import json
2
+ import http.client
3
+ import select
4
+ import subprocess
5
+ from base64 import b64encode
6
+ from typing import Dict, List
7
+
8
+ from docker_stack.helpers import Command
9
+ from docker_stack.url_parser import ConnectionDetails, parse_url
10
+ import os
11
+ class DockerRegistry:
12
+ def __init__(self, _registries:List[str]):
13
+ """
14
+ Initializes the DockerRegistry class with the registry URL, and optional
15
+ username and password for authentication.
16
+
17
+ :param registry_url: URL of the Docker registry (e.g., 'registry.hub.docker.com')
18
+ :param username: Optional username for authentication
19
+ :param password: Optional password for authentication
20
+ """
21
+ registries = [ parse_url(registry) for registry in _registries]
22
+ for registry in registries:
23
+ splitted=registry["host"].split(':')
24
+ if len(splitted)> 1:
25
+ if splitted[1] =='443':
26
+ registry['host']=splitted[0]
27
+ registry["scheme"] = "https"
28
+
29
+ self.registries:Dict[str,ConnectionDetails] = {x["host"]:x for x in self.load_system_connections()}
30
+ for reg in registries:
31
+ self.registries[reg["host"]]=reg
32
+
33
+ self.authenticated=set()
34
+
35
+ def load_system_connections(self) -> List[ConnectionDetails]:
36
+ # Determine the path to the Docker config file
37
+ home_dir = os.getenv('HOME', '/root')
38
+ config_path = os.path.join(home_dir, '.docker', 'config.json')
39
+
40
+ # Initialize an empty list to store connection details
41
+ connections = []
42
+
43
+ try:
44
+ # Open and read the Docker config file
45
+ with open(config_path, 'r') as file:
46
+ config = json.load(file)
47
+
48
+ # Extract the 'auths' section
49
+ auths = config.get('auths', {})
50
+ for host, auth_info in auths.items():
51
+ # Extract the auth token (username:password in base64)
52
+ auth_token = auth_info.get('auth', '')
53
+ if auth_token:
54
+ # Decode the base64 token to get username:password
55
+ import base64
56
+ decoded_token = base64.b64decode(auth_token).decode('utf-8')
57
+ username, password = decoded_token.split(':', 1)
58
+
59
+ # Create a ConnectionDetails dictionary
60
+ connection: ConnectionDetails = {
61
+ 'scheme': 'https', # Docker registries typically use HTTPS
62
+ 'host': host,
63
+ 'username': username,
64
+ 'password': password
65
+ }
66
+ connections.append(connection)
67
+
68
+ except FileNotFoundError:
69
+ print("[Docker Config Load] Docker config file not found.")
70
+ except json.JSONDecodeError:
71
+ print("[Docker Config Load] Invalid JSON in Docker config file.")
72
+ except Exception as e:
73
+ print(f"[Docker Config Load] An error occurred: {e}")
74
+
75
+ return connections
76
+
77
+
78
+ def _get_host_from_url(self, url:str):
79
+ """Extracts the host from the URL."""
80
+ # Remove protocol part (http:// or https://)
81
+ if url.startswith('http://') or url.startswith("https://"):
82
+ return url.split('://')[1].split('/')[0]
83
+ return url
84
+
85
+ def _send_request(self, conn:ConnectionDetails ,method, endpoint)->http.client.HTTPResponse:
86
+ """Send a generic HTTP request to the Docker registry."""
87
+ connection = http.client.HTTPSConnection(conn["host"]) if conn["scheme"]=='https' else http.client.HTTPConnection(conn["host"])
88
+
89
+ # Add Authorization header if needed
90
+ headers = {}
91
+ if conn["username"]:
92
+ auth_string=conn['username']+':'+conn['password']
93
+ headers['Authorization'] = f"Basic {b64encode(auth_string.encode()).decode()}"
94
+
95
+ connection.request(method, endpoint, headers=headers)
96
+ response = connection.getresponse()
97
+ return response
98
+
99
+ def check_auth(self,conn:ConnectionDetails):
100
+ """
101
+ Check if the authentication credentials (if provided) are valid for the Docker registry.
102
+
103
+ :return: Boolean indicating whether authentication is successful
104
+ """
105
+ url = "/v2/"
106
+ response = self._send_request(conn,'GET', url)
107
+ if response.status == 200:
108
+ self.authenticated.add(conn["host"])
109
+ return True
110
+
111
+
112
+ def check_image(self, image_name):
113
+ """
114
+ Check if an image exists in the Docker registry.
115
+
116
+ :param image_name: Name of the image (e.g., 'ubuntu' or 'python')
117
+ :return: Boolean indicating whether the image exists in the registry
118
+ """
119
+ self.login_for_image(image_name)
120
+ hostname=extract_host_from_image_name(image_name)
121
+ url = f"/v2/{image_name}/tags/list"
122
+
123
+ if hostname in self.registries:
124
+ response = self._send_request(self.registries[hostname],'GET',url)
125
+ else:
126
+ registry=parse_url(hostname)
127
+ response = self._send_request(registry,'GET',url)
128
+ print("response",response.read())
129
+ return response.status == 200
130
+
131
+ def _run_docker_command(self, command):
132
+ """
133
+ Run a Docker command using the subprocess module and stream the output to the terminal in real-time.
134
+
135
+ :param command: A list of strings representing the Docker command to run
136
+ :return: None
137
+ """
138
+ try:
139
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
140
+
141
+ # Use select to handle both stdout and stderr without blocking
142
+ while process.poll() is None:
143
+ readable, _, _ = select.select([process.stdout, process.stderr], [], [], 0.1)
144
+ for stream in readable:
145
+ line = stream.readline()
146
+ if line:
147
+ print(line, end="", flush=True)
148
+
149
+ # Ensure remaining output is printed
150
+ for stream in (process.stdout, process.stderr):
151
+ for line in iter(stream.readline, ""):
152
+ print(line, end="", flush=True)
153
+
154
+ process.stdout.close()
155
+ process.stderr.close()
156
+ process.wait()
157
+
158
+ if process.returncode != 0:
159
+ print(f"Command failed with return code {process.returncode}")
160
+
161
+ except FileNotFoundError:
162
+ print("Docker command not found. Please ensure Docker is installed and accessible.")
163
+
164
+ def _run_docker_command_(self, command):
165
+ """
166
+ Run a Docker command using the subprocess module.
167
+
168
+ :param command: A list of strings representing the Docker command to run
169
+ :return: Tuple of (stdout, stderr)
170
+ """
171
+ try:
172
+ result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
173
+ return result.stdout, result.stderr
174
+ except FileNotFoundError:
175
+ return '', 'Docker not found. Please install Docker.'
176
+
177
+ def push(self, image_name)->Command:
178
+ """
179
+ Push an image to the Docker registry.
180
+
181
+ :param image_name: Name of the image to push (e.g., 'myrepo/myimage:tag')
182
+ :return: Tuple of (stdout, stderr)
183
+ """
184
+ self.login_for_image(image_name)
185
+ return Command(['docker', 'push', image_name])
186
+
187
+ def pull(self, image_name):
188
+ """
189
+ Pull an image from the Docker registry.
190
+
191
+ :param image_name: Name of the image to pull (e.g., 'myrepo/myimage:tag')
192
+ :return: Tuple of (stdout, stderr)
193
+ """
194
+ self.login_for_image(image_name)
195
+ command = ['docker', 'pull', image_name]
196
+ return self._run_docker_command(command)
197
+
198
+
199
+ def login_for_image(self,image):
200
+ hostname= extract_host_from_image_name(image)
201
+ if hostname in self.authenticated:
202
+ return True
203
+ if hostname in self.registries and self.check_auth(self.registries[hostname]):
204
+ self.authenticated.add(hostname)
205
+ else:
206
+ registry=self.registries.get(hostname)
207
+ if registry:
208
+ print("> " ," ".join(['docker','login','-u',registry["username"],'-p','[redacted]',registry["host"]]) )
209
+ subprocess.run(['docker','login','-u',registry['username'],'-p',registry['password'],registry["host"]])
210
+ self.authenticated.add(hostname)
211
+ return hostname
212
+
213
+
214
+ def extract_host_from_image_name(image_name: str) -> str:
215
+ """
216
+ Extracts the hostname (registry address) from a Docker image name.
217
+
218
+ :param image_name: Docker image name (e.g., 'ubuntu', 'myregistry.com/myimage', or 'myregistry.com/myrepo/myimage:tag')
219
+ :return: Hostname (e.g., 'docker.io', 'myregistry.com')
220
+ """
221
+ # Remove protocol part (http:// or https://) if present
222
+ if image_name.startswith('http://'):
223
+ image_name = image_name[len('http://'):]
224
+ elif image_name.startswith('https://'):
225
+ image_name = image_name[len('https://'):]
226
+
227
+ # Check if the image name has a registry/hostname
228
+ if '/' in image_name:
229
+ parts = image_name.split('/', 1)
230
+ # If it looks like a full URL (e.g., myregistry.com/myimage:tag)
231
+ if '.' in parts[0] and not parts[0].startswith('http'):
232
+ splitted=parts[0].split(':')
233
+ if len(splitted)> 1:
234
+ if splitted[1] =='443':
235
+ return splitted[0]
236
+ return parts[0]
237
+ # If it looks like a username/repository (e.g., 'username/myimage')
238
+ return 'docker.io'
239
+ return 'docker.io'
@@ -0,0 +1,171 @@
1
+ import re
2
+ from typing import TypedDict
3
+
4
+ class ConnectionDetails(TypedDict):
5
+ scheme: str
6
+ host: str
7
+ username: str
8
+ password: str
9
+
10
+ class URLParsingError(Exception):
11
+ """Custom exception raised when URL parsing fails."""
12
+ pass
13
+
14
+ def is_valid_hostname(hostname: str) -> bool:
15
+ """
16
+ https://stackoverflow.com/a/33214423/2804342
17
+ :return: True if for valid hostname False otherwise
18
+ """
19
+ if hostname[-1] == ".":
20
+ # strip exactly one dot from the right, if present
21
+ hostname = hostname[:-1]
22
+ if len(hostname) > 253:
23
+ return False
24
+
25
+ labels = hostname.split(".")
26
+
27
+ # the TLD must be not all-numeric
28
+ if re.match(r"[0-9]+$", labels[-1]):
29
+ return False
30
+
31
+ allowed = re.compile(r"(?!-)[a-z0-9-]{1,63}(?<!-)$", re.IGNORECASE)
32
+ return all(allowed.match(label) for label in labels)
33
+ def is_valid_hostport(str):
34
+ results=str.split(':')
35
+ def is_valid_integer(s):
36
+ try:
37
+ int(s) # Try converting the string to an integer
38
+ return True
39
+ except ValueError:
40
+ return False
41
+ if len(results)==2:
42
+ return is_valid_hostname(results[0]) and is_valid_integer(results[1])
43
+ elif len(results)==1:
44
+ return is_valid_hostname(results[0])
45
+ else:
46
+ return False
47
+ def parse_url(url)-> ConnectionDetails:
48
+ """
49
+ Parses a URL and returns a dictionary with scheme, host, username, and password.
50
+
51
+ Args:
52
+ url (str): The URL string to be parsed.
53
+
54
+ Returns:
55
+ dict: A dictionary containing parsed components of the URL.
56
+ {
57
+ 'scheme': 'https' or 'http',
58
+ 'host': The host of the URL,
59
+ 'username': The username (if any),
60
+ 'password': The password (if any)
61
+ }
62
+
63
+ Raises:
64
+ URLParsingError: If the URL cannot be parsed.
65
+ """
66
+ # Regular expression to match the URL with scheme, host:username:password or host:port:username:password format
67
+ pattern = re.compile(r'^(?P<scheme>https?|ftp)://(?:(?P<username>[^:@]+)(?::(?P<password>[^@]+))?@)?(?P<host>[^:/]+)(?::(?P<port>\d+))?$')
68
+
69
+ # Attempt to match the URL with the regular expression
70
+ match = pattern.match(url)
71
+
72
+ if match:
73
+ scheme = match.group('scheme') if match.group('scheme') else 'https' # Default to https
74
+ host = match.group('host')
75
+ port = match.group('port')
76
+ username = match.group('username')
77
+ password = match.group('password')
78
+
79
+ # If there's a port, append it to the host
80
+ if port:
81
+ host = f"{host}:{port}"
82
+
83
+ return {
84
+ "scheme": scheme,
85
+ "host": host,
86
+ "username": username,
87
+ "password": password
88
+ }
89
+
90
+ # Handle special cases where the URL is in host:username:password or host:port:username:password format (may include scheme)
91
+ special_match = re.match(r'^(?P<scheme>https?|ftp)://(?P<host>[^:/]+)(?::(?P<port>\d+))?:(?P<username>[^:]+):(?P<password>.+)$', url)
92
+ if special_match:
93
+ scheme = special_match.group('scheme') if special_match.group('scheme') else 'https' # Default to https
94
+ host = special_match.group('host')
95
+ port = special_match.group('port')
96
+ username = special_match.group('username')
97
+ password = special_match.group('password')
98
+
99
+ # If there's a port, append it to the host
100
+ if port:
101
+ host = f"{host}:{port}"
102
+
103
+ return {
104
+ "scheme": scheme,
105
+ "host": host,
106
+ "username": username,
107
+ "password": password
108
+ }
109
+
110
+
111
+ # Handle special case where no scheme is given, but it is in host:username:password or host:port:username:password format
112
+ special_no_scheme_match = re.match(r'^(?P<host>[^:/]+)(?::(?P<port>\d+))?:(?P<username>[^:]+):(?P<password>.+)$', url)
113
+ if special_no_scheme_match:
114
+ host = special_no_scheme_match.group('host')
115
+ port = special_no_scheme_match.group('port')
116
+ username = special_no_scheme_match.group('username')
117
+ password = special_no_scheme_match.group('password')
118
+
119
+ # If there's a port, append it to the host
120
+ if port:
121
+ host = f"{host}:{port}"
122
+
123
+ return {
124
+ "scheme": 'https', # Default to https for the special case
125
+ "host": host,
126
+ "username": username,
127
+ "password": password
128
+ }
129
+
130
+ simple_regex = r'^(?:(?P<username>\S+):(?P<password>\S+)@)?(?P<hostport>\S+)(?::\d+)?$'
131
+ match = re.match(simple_regex, url)
132
+
133
+ if match:
134
+ username = match.group("username")
135
+ password = match.group("password")
136
+ hostport = match.group("hostport")
137
+ if is_valid_hostport(hostport):
138
+ return {
139
+ "scheme": "https",
140
+ "host": hostport,
141
+ "username": username,
142
+ "password": password
143
+ }
144
+ # If no match was found, raise a parsing error
145
+ raise URLParsingError(f"Failed to parse URL: {url}")
146
+
147
+
148
+ # Run tests when this file is executed directly
149
+ if __name__ == "__main__":
150
+ # Test cases
151
+ test_urls = [
152
+ ("registry.sireto.io", {'scheme': 'https', 'host': 'registry.sireto.io', 'username': None, 'password': None}),
153
+ ("user:password@registry.sireto.io", {'scheme': 'https', 'host': 'registry.sireto.io', 'username': 'user', 'password': 'password'}),
154
+ ("https://registry.sireto.io", {'scheme': 'https', 'host': 'registry.sireto.io', 'username': None, 'password': None}),
155
+ ("https://user:password@registry.sireto.io", {'scheme': 'https', 'host': 'registry.sireto.io', 'username': 'user', 'password': 'password'}),
156
+ ("registry.sireto.io:user:password", {'scheme': 'https', 'host': 'registry.sireto.io', 'username': 'user', 'password': 'password'}),
157
+ ("registry.sireto.io:5050:user:password", {'scheme': 'https', 'host': 'registry.sireto.io:5050', 'username': 'user', 'password': 'password'}),
158
+ ("http://registry.sireto.io:5050:user:password", {'scheme': 'http', 'host': 'registry.sireto.io:5050', 'username': 'user', 'password': 'password'}),
159
+ ("https://registry.sireto.io:5050:user:password", {'scheme': 'https', 'host': 'registry.sireto.io:5050', 'username': 'user', 'password': 'password'}),
160
+ ("registry.sireto.io:5050:user:password", {'scheme': 'https', 'host': 'registry.sireto.io:5050', 'username': 'user', 'password': 'password'}),
161
+ ("registry.sireto.io:user:password", {'scheme': 'https', 'host': 'registry.sireto.io', 'username': 'user', 'password': 'password'})
162
+ ]
163
+
164
+ for url, expected in test_urls:
165
+ try:
166
+ print(f"Testing URL: {url}")
167
+ result = parse_url(url)
168
+ assert result == expected, f"Test failed for {url}. Expected {expected}, got {result}"
169
+ print(f"Success: {result}")
170
+ except Exception as e:
171
+ print(f"Error: {e}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: docker-stack
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: CLI for deploying and managing Docker stacks.
5
5
  Home-page: https://github.com/mesuidp/docker-stack
6
6
  Author: Sudip Bhattarai
@@ -10,6 +10,7 @@ docker_stack/envsubst_merge.py
10
10
  docker_stack/helpers.py
11
11
  docker_stack/merge_conf.py
12
12
  docker_stack/registry.py
13
+ docker_stack/url_parser.py
13
14
  docker_stack.egg-info/PKG-INFO
14
15
  docker_stack.egg-info/SOURCES.txt
15
16
  docker_stack.egg-info/dependency_links.txt
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="docker-stack",
5
- version="0.1.1",
5
+ version="0.2.1",
6
6
  description="CLI for deploying and managing Docker stacks.",
7
7
  long_description=open("README.md").read(), # You can include a README file to describe your package
8
8
  long_description_content_type="text/markdown",
@@ -1,169 +0,0 @@
1
- #!/usr/bin/env python3
2
- import argparse
3
- import sys
4
- from pathlib import Path
5
- from typing import List
6
- from docker_stack import DockerConfig, DockerSecret
7
- import os
8
- import yaml
9
- import json
10
- from docker_stack.docker_objects import DockerObjectManager
11
- from docker_stack.helpers import Command
12
- from docker_stack.registry import DockerRegistry
13
- from .envsubst import envsubst
14
-
15
- class Docker:
16
- def __init__(self,resgistry_url='https://docker.io',userpass=''):
17
- self.stack = DockerStack(self)
18
- self.config = DockerConfig()
19
- self.secret = DockerSecret()
20
- self.registry = DockerRegistry(resgistry_url, userpass)
21
-
22
- @staticmethod
23
- def load_env(env_file=".env"):
24
- if Path(env_file).is_file():
25
- with open(env_file) as f:
26
- for line in f:
27
- line = line.strip()
28
- if line and not line.startswith("#"):
29
- key, _, value = line.partition("=")
30
- os.environ[key.strip()] = value.strip()
31
-
32
- @staticmethod
33
- def check_env(example_file=".env.example"):
34
- if not Path(example_file).is_file():
35
- return
36
-
37
- unset_keys = []
38
- with open(example_file) as f:
39
- for line in f:
40
- line = line.strip()
41
- if line and not line.startswith("#"):
42
- key = line.split("=")[0].strip()
43
- if not os.environ.get(key):
44
- unset_keys.append(key)
45
-
46
- if unset_keys:
47
- print("The following keys are not set in the environment:")
48
- for key in unset_keys:
49
- print(f"- {key}")
50
- print("Exiting due to missing environment variables.")
51
- sys.exit(2)
52
-
53
- class DockerStack:
54
- def __init__(self, docker: Docker):
55
- self.docker = docker
56
- self.commands: List[Command] = []
57
-
58
- def render_compose_file(self, compose_file):
59
- """
60
- Render the Docker Compose file with environment variables and create Docker configs/secrets.
61
- """
62
- with open(compose_file) as f:
63
- template_content = f.read()
64
- rendered_content = envsubst(template_content)
65
-
66
- # Parse the YAML content
67
- compose_data = yaml.safe_load(rendered_content)
68
-
69
- # Process configs and secrets with x-content
70
- if "configs" in compose_data:
71
- compose_data["configs"] = self._process_x_content(compose_data["configs"], self.docker.config)
72
- if "secrets" in compose_data:
73
- compose_data["secrets"] = self._process_x_content(compose_data["secrets"], self.docker.secret)
74
-
75
- # Convert the modified data back to YAML
76
-
77
- rendered_content = yaml.dump(compose_data)
78
-
79
- # Write the rendered file
80
- rendered_filename = Path(compose_file).with_name(
81
- f"{Path(compose_file).stem}-rendered{Path(compose_file).suffix}"
82
- )
83
- with open(rendered_filename, "w") as f:
84
- f.write(rendered_content)
85
- with open(rendered_filename.as_posix()+".json","w") as f:
86
- f.write(json.dumps(compose_data,indent=2))
87
- return (rendered_filename,rendered_content)
88
-
89
-
90
-
91
- def _process_x_content(self, objects, manager:DockerObjectManager):
92
- """
93
- Process configs or secrets with x-content keys.
94
- Returns a tuple: (processed_objects, commands)
95
- """
96
- processed_objects = {}
97
- for name, details in objects.items():
98
- if isinstance(details, dict) and "x-content" in details:
99
- # Create the Docker object (config or secret)
100
- (object_name,command)=manager.create(name, details['x-content'])
101
- if not command.isNop():
102
- self.commands.append(command)
103
- # Replace x-content with the name of the created object
104
- processed_objects[name] = {"name": object_name,"external": True}
105
- else:
106
- processed_objects[name] = details
107
- return processed_objects
108
-
109
- def deploy(self, stack_name, compose_file, with_registry_auth=False):
110
- rendered_filename, rendered_content = self.render_compose_file(compose_file)
111
- _, cmd = self.docker.config.increment(stack_name, rendered_content, [f"mesudip.stack.name={stack_name}"])
112
- if not cmd.isNop():
113
- self.commands.append(cmd)
114
- cmd = ["docker", "stack", "deploy", "-c", str(rendered_filename), stack_name]
115
- if with_registry_auth:
116
- cmd.insert(3, "--with-registry-auth")
117
- self.commands.append(Command(cmd))
118
-
119
- def push(self, compose_file, credentials):
120
- with open(compose_file) as f:
121
- compose_data = yaml.safe_load(f)
122
- for service_name, service_data in compose_data.get("services", {}).items():
123
- if "build" in service_data:
124
- build_path = service_data["build"]
125
- print(f"++ docker build -t {service_data['image']} {build_path}")
126
- build_command = ["docker", "build", "-t", service_data['image'], build_path.get('context', '.')]
127
- self.commands.append(Command(build_command))
128
- push_result = self.check_and_push_pull_image(service_data['image'], 'push')
129
- if push_result:
130
- self.commands.append(push_result)
131
- else:
132
- print("No need to push: Already exists")
133
-
134
- def check_and_push_pull_image(self, image_name: str, action: str):
135
- if self.docker.registry.check_image(image_name):
136
- print(f"Image {image_name} already in the registry.")
137
- return None
138
- if action == 'push':
139
- print(f"Pushing image {image_name} to the registry...")
140
- cmd = self.docker.registry.push(image_name)
141
- if cmd:
142
- self.commands.append(cmd)
143
-
144
-
145
- def main():
146
- parser = argparse.ArgumentParser(description="Deploy and manage Docker stacks.")
147
- parser.add_argument("command", choices=["deploy", "push"], help="Command to execute")
148
- parser.add_argument("stack_name", help="Name of the stack", nargs="?")
149
- parser.add_argument("compose_file", help="Path to the compose file")
150
- parser.add_argument("--with-registry-auth", action="store_true", help="Use registry authentication")
151
- parser.add_argument("-u", "--user", help="Registry credentials in format username:password", required=False)
152
-
153
- args = parser.parse_args()
154
- docker = Docker(resgistry_url="https://registry.sireto.io",userpass='admin:69a017f5de7509e5e7ab0e89a5687dbda58f4fa70762bee17d2e454704bd7a4f')
155
- docker.load_env()
156
- docker.check_env()
157
- docker.registry.login()
158
-
159
- if args.command == "push":
160
- docker.stack.push(args.compose_file, args.user)
161
- else:
162
- docker.stack.deploy(args.stack_name, args.compose_file, args.with_registry_auth)
163
-
164
- print("Commands to Execute")
165
- [print(" >", x) for x in docker.stack.commands] if docker.stack.commands else print("-- empty --")
166
- [x.execute() for x in docker.stack.commands]
167
-
168
- if __name__ == "__main__":
169
- main()
@@ -1,150 +0,0 @@
1
- import json
2
- import http.client
3
- import select
4
- import subprocess
5
- from base64 import b64encode
6
-
7
- from docker_stack.helpers import Command
8
-
9
- class DockerRegistry:
10
- def __init__(self, registry_url, userpass:str=None):
11
- """
12
- Initializes the DockerRegistry class with the registry URL, and optional
13
- username and password for authentication.
14
-
15
- :param registry_url: URL of the Docker registry (e.g., 'registry.hub.docker.com')
16
- :param username: Optional username for authentication
17
- :param password: Optional password for authentication
18
- """
19
- self.registry_url = registry_url
20
- self.username = None
21
- self.password = None
22
- if userpass:
23
- splitted=userpass.split(':')
24
-
25
- self.username=splitted[0]
26
- self.password=splitted[1]
27
-
28
- # Parse registry host and port
29
- self.host = self._get_host_from_url(registry_url)
30
- self.port = 443 if self.registry_url.startswith("https") else 80
31
-
32
- def _get_host_from_url(self, url):
33
- """Extracts the host from the URL."""
34
- # Remove protocol part (http:// or https://)
35
- return url.split('://')[1].split('/')[0]
36
-
37
- def _send_request(self, method, endpoint, auth=None):
38
- """Send a generic HTTP request to the Docker registry."""
39
- connection = http.client.HTTPSConnection(self.host, self.port)
40
-
41
- # Add Authorization header if needed
42
- headers = {}
43
- if auth:
44
- headers['Authorization'] = f"Basic {b64encode(auth.encode()).decode()}"
45
-
46
- connection.request(method, endpoint, headers=headers)
47
- response = connection.getresponse()
48
- return response
49
-
50
- def check_auth(self):
51
- """
52
- Check if the authentication credentials (if provided) are valid for the Docker registry.
53
-
54
- :return: Boolean indicating whether authentication is successful
55
- """
56
- url = "/v2/"
57
- if self.username and self.password:
58
- auth = f"{self.username}:{self.password}"
59
- response = self._send_request('GET', url, auth)
60
- else:
61
- response = self._send_request('GET', url)
62
-
63
- # Check if the status code is 200
64
- return response.status == 200
65
-
66
- def check_image(self, image_name):
67
- """
68
- Check if an image exists in the Docker registry.
69
-
70
- :param image_name: Name of the image (e.g., 'ubuntu' or 'python')
71
- :return: Boolean indicating whether the image exists in the registry
72
- """
73
- url = f"/v2/{image_name}/tags/list"
74
- if self.username and self.password:
75
- auth = f"{self.username}:{self.password}"
76
- response = self._send_request('GET', url, auth)
77
- else:
78
- response = self._send_request('GET', url)
79
-
80
- # Check if the status code is 200
81
- return response.status == 200
82
-
83
- def _run_docker_command(self, command):
84
- """
85
- Run a Docker command using the subprocess module and stream the output to the terminal in real-time.
86
-
87
- :param command: A list of strings representing the Docker command to run
88
- :return: None
89
- """
90
- try:
91
- process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
92
-
93
- # Use select to handle both stdout and stderr without blocking
94
- while process.poll() is None:
95
- readable, _, _ = select.select([process.stdout, process.stderr], [], [], 0.1)
96
- for stream in readable:
97
- line = stream.readline()
98
- if line:
99
- print(line, end="", flush=True)
100
-
101
- # Ensure remaining output is printed
102
- for stream in (process.stdout, process.stderr):
103
- for line in iter(stream.readline, ""):
104
- print(line, end="", flush=True)
105
-
106
- process.stdout.close()
107
- process.stderr.close()
108
- process.wait()
109
-
110
- if process.returncode != 0:
111
- print(f"Command failed with return code {process.returncode}")
112
-
113
- except FileNotFoundError:
114
- print("Docker command not found. Please ensure Docker is installed and accessible.")
115
-
116
- def _run_docker_command_(self, command):
117
- """
118
- Run a Docker command using the subprocess module.
119
-
120
- :param command: A list of strings representing the Docker command to run
121
- :return: Tuple of (stdout, stderr)
122
- """
123
- try:
124
- result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
125
- return result.stdout, result.stderr
126
- except FileNotFoundError:
127
- return '', 'Docker not found. Please install Docker.'
128
-
129
- def push(self, image_name)->Command:
130
- """
131
- Push an image to the Docker registry.
132
-
133
- :param image_name: Name of the image to push (e.g., 'myrepo/myimage:tag')
134
- :return: Tuple of (stdout, stderr)
135
- """
136
- return Command(['docker', 'push', image_name])
137
-
138
- def pull(self, image_name):
139
- """
140
- Pull an image from the Docker registry.
141
-
142
- :param image_name: Name of the image to pull (e.g., 'myrepo/myimage:tag')
143
- :return: Tuple of (stdout, stderr)
144
- """
145
- command = ['docker', 'pull', image_name]
146
- return self._run_docker_command(command)
147
-
148
- def login(self):
149
- print("> " ,['docker','login','-u',self.username,'-p',self.password])
150
- subprocess.run(['docker','login','-u',self.username,'-p',self.password,self.host])
File without changes
File without changes