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.
- {docker-stack-0.1.1 → docker-stack-0.2.1}/PKG-INFO +1 -1
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/__init__.py +2 -1
- docker-stack-0.2.1/docker_stack/cli.py +238 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/compose.py +1 -1
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/docker_objects.py +6 -4
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/envsubst.py +37 -4
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/helpers.py +17 -5
- docker-stack-0.2.1/docker_stack/registry.py +239 -0
- docker-stack-0.2.1/docker_stack/url_parser.py +171 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/PKG-INFO +1 -1
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/SOURCES.txt +1 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/setup.py +1 -1
- docker-stack-0.1.1/docker_stack/cli.py +0 -169
- docker-stack-0.1.1/docker_stack/registry.py +0 -150
- {docker-stack-0.1.1 → docker-stack-0.2.1}/README.md +0 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/envsubst_merge.py +0 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack/merge_conf.py +0 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/requires.txt +0 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/docker_stack.egg-info/top_level.txt +0 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/pyproject.toml +0 -0
- {docker-stack-0.1.1 → docker-stack-0.2.1}/setup.cfg +0 -0
|
@@ -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([""])
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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}")
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|