docker-stack 0.1.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.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.1
2
+ Name: docker-stack
3
+ Version: 0.1.1
4
+ Summary: CLI for deploying and managing Docker stacks.
5
+ Home-page: https://github.com/mesuidp/docker-stack
6
+ Author: Sudip Bhattarai
7
+ Author-email: sudip.dev.np@gmail.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.6
12
+ Description-Content-Type: text/markdown
13
+
14
+ Docker Stack
15
+ ==============
16
+ cli utility for stack deployment in docker-swarm.
17
+ #### Features
18
+ - docker config and secret creation and versioning
19
+ - docker stack versioning and config backup for rollback
@@ -0,0 +1,6 @@
1
+ Docker Stack
2
+ ==============
3
+ cli utility for stack deployment in docker-swarm.
4
+ #### Features
5
+ - docker config and secret creation and versioning
6
+ - docker stack versioning and config backup for rollback
@@ -0,0 +1,21 @@
1
+ from .envsubst import envsubst
2
+ from .compose import read_compose_file
3
+ from .docker_objects import DockerConfig, DockerSecret
4
+ from .cli import main
5
+
6
+ """
7
+ Functions:
8
+
9
+ 1. read_compose_file(compose_file_path):
10
+ - Reads a Docker Compose YAML file and returns its contents as a dictionary.
11
+
12
+ 2. create_config(config_name, config_content):
13
+ - Creates a Docker config from the given string content. Adds a label with the SHA256 hash of the config content.
14
+
15
+ 3. check_config(config_name):
16
+ - Checks if a Docker config with the given name already exists.
17
+
18
+ 4. update_config(config_name, config_content):
19
+ - Updates an existing Docker config or creates a new one if it doesn't exist.
20
+ If the config exists, checks the SHA256 hash and recreates the config if the hash doesn't match.
21
+ """
@@ -0,0 +1,169 @@
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()
@@ -0,0 +1,17 @@
1
+ import yaml
2
+
3
+
4
+ def read_compose_file(compose_file_path):
5
+ """
6
+ Reads a Docker Compose YAML file and returns its contents as a dictionary.
7
+
8
+ :param compose_file_path: Path to the Docker Compose file.
9
+ :return: Dictionary representation of the YAML contents.
10
+ """
11
+ if not os.path.exists(compose_file_path):
12
+ raise FileNotFoundError(f"Compose file {compose_file_path} does not exist.")
13
+
14
+ with open(compose_file_path, "r") as file:
15
+ compose_data = yaml.safe_load(file)
16
+
17
+ return compose_data
@@ -0,0 +1,221 @@
1
+ import hashlib
2
+ import re
3
+ import json
4
+ from typing import List, Tuple
5
+ from docker_stack.helpers import Command, run_cli_command # Import the helper function
6
+
7
+
8
+ class DockerObjectManager:
9
+ def __init__(self, object_type, log=False):
10
+ self.object_type = object_type
11
+ self.log = log
12
+
13
+ def _create(
14
+ self, object_name, object_content, labels: List[str] = [], sha_hash=None
15
+ ):
16
+ sha_hash = (
17
+ sha_hash if sha_hash else self.calculate_hash(object_name, object_content)
18
+ )
19
+ command = ["docker", self.object_type, "create"]
20
+ command.append("--label")
21
+ command.append(f"sha256={sha_hash}")
22
+
23
+ for label in labels:
24
+ command.append("--label")
25
+ command.append(label.strip())
26
+
27
+ command.append(object_name)
28
+ command.append("-")
29
+
30
+ # Return a Command object with the object_content as stdin
31
+ return Command(command, stdin=object_content, id=sha_hash)
32
+
33
+ def calculate_hash(self, object_name, object_content):
34
+ hash_input = f"{self.object_type}{object_name}{object_content}"
35
+ return hashlib.sha256(hash_input.encode("utf-8")).hexdigest()
36
+
37
+ def check(self, object_name):
38
+ command = ["docker", self.object_type, "inspect", object_name]
39
+
40
+ try:
41
+ # Use run_cli_command instead of subprocess.run
42
+ run_cli_command(command, raise_error=True, log=self.log)
43
+ return True
44
+ except Exception:
45
+ return False
46
+
47
+ def create(
48
+ self, object_name, object_content, labels: List[str] = []
49
+ ) -> Tuple[str, Command]:
50
+ sha_hash = self.calculate_hash(object_name, object_content)
51
+
52
+ # Check if any version of the object already exists by its label
53
+ command = [
54
+ "docker",
55
+ self.object_type,
56
+ "ls",
57
+ "--filter",
58
+ f"label=mesudip.object.name={object_name}",
59
+ "--format",
60
+ "{{json .}}",
61
+ ]
62
+ output = run_cli_command(command, raise_error=True, log=self.log)
63
+
64
+ # Parse existing versions
65
+ existing_versions = {}
66
+ max_version = 0
67
+
68
+ for line in output.splitlines():
69
+ object_info = json.loads(line)
70
+ object_name_in_docker = object_info["Name"]
71
+ if object_name_in_docker == object_name:
72
+ max_version = max(max_version, 1)
73
+ existing_versions[1] = object_info
74
+ else:
75
+ match = re.search(r"_(v\d+)$", object_name_in_docker)
76
+ if match:
77
+ version = int(match.group(1)[1:])
78
+ max_version = max(version, max_version)
79
+ existing_versions[version] = object_info
80
+
81
+ # Determine the next version number
82
+ new_version_suffix = ""
83
+ if len(existing_versions) > 0:
84
+ new_version = max_version + 1
85
+ new_version_suffix = f"_v{new_version}"
86
+ else:
87
+ new_version = 1
88
+
89
+ new_object_name = f"{object_name}{new_version_suffix}"
90
+
91
+ # Check if the SHA hash for the new content already exists in any version
92
+ existing_sha_hash = None
93
+ matching_object = None
94
+
95
+ for object_info in existing_versions.values():
96
+ object_name_in_docker = object_info["Name"]
97
+ parsed_labels = parse_labels(object_info["Labels"])
98
+ object_sha_hash = parsed_labels.get("sha256")
99
+
100
+ if object_sha_hash == sha_hash:
101
+ existing_sha_hash = sha_hash
102
+ matching_object = object_info
103
+ break
104
+
105
+ if existing_sha_hash == sha_hash:
106
+ existing_name = matching_object["Name"]
107
+ if self.log:
108
+ print(
109
+ f"{self.object_type.capitalize()} {existing_name} already exists with the same SHA hash. No update needed."
110
+ )
111
+ return existing_name, Command.nop
112
+
113
+ if self.log:
114
+ print(f"SHA mismatch. Creating a new version: {new_object_name}")
115
+ labels = [
116
+ f"mesudip.object.version={new_version:01d}",
117
+ f"mesudip.object.name={object_name}",
118
+ ] + labels
119
+ return new_object_name, self._create(
120
+ new_object_name, object_content, labels, sha_hash=sha_hash
121
+ )
122
+
123
+ def increment(
124
+ self, object_name, object_content, labels: List[str] = []
125
+ ) -> Tuple[str, Command]:
126
+ sha_hash = self.calculate_hash(object_name, object_content)
127
+
128
+ # Check if any version of the object already exists by its label
129
+ command = [
130
+ "docker",
131
+ self.object_type,
132
+ "ls",
133
+ "--filter",
134
+ f"label=mesudip.object.name={object_name}",
135
+ "--format",
136
+ "{{json .}}",
137
+ ]
138
+ output = run_cli_command(command, raise_error=True, log=self.log)
139
+
140
+ # Parse existing versions
141
+ existing_versions = {}
142
+ max_version = 0
143
+
144
+ for line in output.splitlines():
145
+ object_info = json.loads(line)
146
+ object_name_in_docker = object_info["Name"]
147
+ if object_name_in_docker == object_name:
148
+ max_version = max(max_version, 1)
149
+ existing_versions[1] = object_info
150
+ else:
151
+ match = re.search(r"_(v\d+)$", object_name_in_docker)
152
+ if match:
153
+ version = int(match.group(1)[1:])
154
+ max_version = max(version, max_version)
155
+ existing_versions[version] = object_info
156
+
157
+ # Determine the next version number
158
+ new_version_suffix = ""
159
+ if len(existing_versions) > 0:
160
+ new_version = max_version + 1
161
+ new_version_suffix = f"_v{new_version}"
162
+ else:
163
+ new_version = 1
164
+
165
+ new_object_name = f"{object_name}{new_version_suffix}"
166
+ last_sha = None
167
+ if max_version > 0:
168
+ last_object = existing_versions[max_version]
169
+ command = [
170
+ "docker",
171
+ self.object_type,
172
+ "inspect",
173
+ last_object["Name"],
174
+ "--format",
175
+ "{{json .Spec.Labels}}",
176
+ ]
177
+ inspect_result = run_cli_command(command, raise_error=True, log=self.log)
178
+ labels_info = json.loads(inspect_result)
179
+ last_sha = labels_info.get("sha256")
180
+
181
+ if sha_hash == last_sha:
182
+ return (last_object["Name"], Command.nop)
183
+ else:
184
+ labels = [
185
+ f"mesudip.object.version={new_version:01d}",
186
+ f"mesudip.object.name={object_name}",
187
+ ] + labels
188
+ return new_object_name, self._create(
189
+ new_object_name, object_content, labels, sha_hash=sha_hash
190
+ )
191
+
192
+
193
+ class DockerConfig(DockerObjectManager):
194
+ def __init__(self, log=False):
195
+ super().__init__("config", log)
196
+
197
+
198
+ class DockerSecret(DockerObjectManager):
199
+ def __init__(self, log=False):
200
+ super().__init__("secret", log)
201
+
202
+
203
+ def parse_labels(label_string):
204
+ """
205
+ Parse a comma-separated string of key-value pairs into a dictionary.
206
+ Handles cases where values may contain commas.
207
+
208
+ Args:
209
+ label_string (str): A string containing key-value pairs separated by commas.
210
+
211
+ Returns:
212
+ dict: A dictionary of labels and their corresponding values.
213
+ """
214
+ labels = {}
215
+ # Use a regex to split on commas that are not part of a value
216
+ pattern = re.compile(r",(?![^=,]*(?:,|$))")
217
+ for pair in pattern.split(label_string):
218
+ if "=" in pair:
219
+ key, value = pair.split("=", 1) # Split on the first '=' only
220
+ labels[key.strip()] = value.strip()
221
+ return labels
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ NAME
4
+ envsubst.py - substitutes environment variables in bash format strings
5
+
6
+ DESCRIPTION
7
+ envsubst.py is an upgrade of the POSIX command `envsubst`
8
+
9
+ supported syntax:
10
+ normal - ${VARIABLE1} or $VARIABLE1
11
+ with default - ${VARIABLE1:-somevalue}
12
+ """
13
+
14
+ import os
15
+ import re
16
+ import sys
17
+
18
+
19
+ def envsubst(template_str, env=os.environ):
20
+ """Substitute environment variables in the template string, supporting default values."""
21
+
22
+ # Regex for ${VARIABLE} with optional default
23
+ pattern_with_default = re.compile(r"\$\{([^}:\s]+)(?::-(.*?))?\}")
24
+
25
+ # Regex for $VARIABLE without default
26
+ pattern_without_default = re.compile(r"\$([a-zA-Z_][a-zA-Z0-9_]*)")
27
+
28
+ def replace_with_default(match):
29
+ var = match.group(1)
30
+ default_value = match.group(2) if match.group(2) is not None else None
31
+ result = env.get(var, default_value)
32
+ if result is None:
33
+ print(f"Missing template variable with default: {var}", file=sys.stderr)
34
+ exit(1)
35
+ return result
36
+
37
+ def replace_without_default(match):
38
+ var = match.group(1)
39
+ result = env.get(var, None)
40
+ if result is None:
41
+ print(f"Missing template variable: {var}", file=sys.stderr)
42
+ exit(1)
43
+ return result
44
+
45
+ # Substitute variables with default values
46
+ template_str = pattern_with_default.sub(replace_with_default, template_str)
47
+
48
+ # Substitute variables without default values
49
+ template_str = pattern_without_default.sub(replace_without_default, template_str)
50
+
51
+ return template_str
52
+
53
+
54
+ def main():
55
+ if len(sys.argv) > 2:
56
+ print("Usage: python envsubst.py [template_file]")
57
+ sys.exit(1)
58
+
59
+ if len(sys.argv) == 2:
60
+ template_file = sys.argv[1]
61
+ with open(template_file, "r") as file:
62
+ template_str = file.read()
63
+ else:
64
+ template_str = sys.stdin.read()
65
+
66
+ result = envsubst(template_str)
67
+
68
+ print(result)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ NAME
4
+ envsubst_merge.py - merges files and substitutes environment variables in the content
5
+
6
+ DESCRIPTION
7
+ envsubst_merge.py combines the functionality of merging files and substituting environment
8
+ variables in bash format strings. An optional file extension can be provided to filter the files to be merged.
9
+ """
10
+
11
+ import os
12
+ import re
13
+ import sys
14
+
15
+
16
+ def envsubst(template_str, env=os.environ):
17
+ """Substitute environment variables in the template string, supporting default values."""
18
+
19
+ # Regex for ${VARIABLE} with optional default
20
+ pattern_with_default = re.compile(r"\$\{([^}:\s]+)(?::-([^}]*))?\}")
21
+
22
+ # Regex for $VARIABLE without default
23
+ pattern_without_default = re.compile(r"\$([a-zA-Z_][a-zA-Z0-9_]*)")
24
+
25
+ def replace_with_default(match):
26
+ var = match.group(1)
27
+ default_value = match.group(2) if match.group(2) is not None else None
28
+ result = env.get(var, default_value)
29
+ if result is None:
30
+ print(f"Missing template variable with default: {var}", file=sys.stderr)
31
+ exit(1)
32
+ return result
33
+
34
+ def replace_without_default(match):
35
+ var = match.group(1)
36
+ result = env.get(var, None)
37
+ if result is None:
38
+ print(f"Missing template variable: {var}", file=sys.stderr)
39
+ exit(1)
40
+ return result
41
+
42
+ # Substitute variables with default values
43
+ template_str = pattern_with_default.sub(replace_with_default, template_str)
44
+
45
+ # Substitute variables without default values
46
+ template_str = pattern_without_default.sub(replace_without_default, template_str)
47
+
48
+ return template_str
49
+
50
+
51
+ def merge_files_from_directories(directories, file_extension=None):
52
+ merged_content = []
53
+
54
+ for path in directories:
55
+ if os.path.isdir(path):
56
+ # If the path is a directory, read files with the specified extension
57
+ for filename in os.listdir(path):
58
+ if not file_extension or filename.endswith(file_extension):
59
+ filepath = os.path.join(path, filename)
60
+ with open(filepath, "r") as file:
61
+ content = (
62
+ file.read().strip()
63
+ ) # Strip leading/trailing whitespace
64
+ if content: # Add only non-empty content
65
+ # Add directory and filename as a comment at the start of the content
66
+ merged_content.append(f"# {path}/{filename}\n{content}")
67
+ elif os.path.isfile(path) and (
68
+ not file_extension or path.endswith(file_extension)
69
+ ):
70
+ # If the path is a file with the specified extension, read its content
71
+ with open(path, "r") as file:
72
+ content = file.read().strip() # Strip leading/trailing whitespace
73
+ if content: # Add only non-empty content
74
+ # Add the file name as a comment at the start of the content
75
+ merged_content.append(f"# {path}\n{content}")
76
+ else:
77
+ print(
78
+ f"Warning: '{path}' is not a valid directory or file. Skipping.",
79
+ file=sys.stderr,
80
+ )
81
+
82
+ # Join the content with a single newline between entries
83
+ result = "\n\n".join(merged_content)
84
+
85
+ # Strip extra empty lines from the beginning and end
86
+ result = result.strip()
87
+
88
+ # Perform environment variable substitution on the final result
89
+ return envsubst(result)
90
+
91
+
92
+ def main():
93
+ # Take directories or files from command-line arguments
94
+ if len(sys.argv) < 2:
95
+ print(
96
+ "Usage: python envsubst_merge.py <directory1|file1> <directory2|file2> ... [--ext <file_extension>] [--ext=<file_extension>]",
97
+ file=sys.stderr,
98
+ )
99
+ sys.exit(1)
100
+
101
+ paths_to_read = []
102
+ file_extension = None
103
+
104
+ # Parse arguments
105
+ args = iter(sys.argv[1:])
106
+ for arg in args:
107
+ if arg.startswith("--ext="):
108
+ file_extension = arg.split("=", 1)[1]
109
+ elif arg == "--ext":
110
+ try:
111
+ file_extension = next(args)
112
+ except StopIteration:
113
+ print("Error: Missing file extension after --ext.", file=sys.stderr)
114
+ sys.exit(1)
115
+ else:
116
+ paths_to_read.append(arg)
117
+
118
+ result = merge_files_from_directories(paths_to_read, file_extension)
119
+
120
+ # Print the output to stdout
121
+ print(result)
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
@@ -0,0 +1,107 @@
1
+ import subprocess
2
+ from typing import List, Optional
3
+
4
+
5
+ def run_cli_command(
6
+ command: List[str],
7
+ stdin: Optional[str] = None,
8
+ raise_error: bool = True,
9
+ log: bool = True,
10
+ shell: bool = False,
11
+ interactive: bool = False,
12
+ ) -> str:
13
+ """
14
+ Run a CLI command and return its output.
15
+
16
+ Args:
17
+ command: The command to run as a list of strings.
18
+ stdin: Input to pass to the command via stdin.
19
+ raise_error: If True, raise an exception if the command fails.
20
+ log: If True, log the command before executing it.
21
+ shell: If True, run the command in a shell.
22
+ interactive: If True, run the command with everything sent to the current console.
23
+
24
+ Returns:
25
+ The stdout of the command as a string, or None if interactive mode is enabled.
26
+ """
27
+ if log:
28
+ print("> " + " ".join(command))
29
+
30
+ try:
31
+ if interactive:
32
+ result = subprocess.run(command, shell=shell)
33
+ return None
34
+ else:
35
+ result = subprocess.run(
36
+ command,
37
+ input=stdin,
38
+ text=True,
39
+ capture_output=True,
40
+ check=raise_error,
41
+ shell=shell,
42
+ )
43
+ return result.stdout.strip()
44
+ except subprocess.CalledProcessError as e:
45
+ # Log the error and re-raise the exception
46
+ print(f"Error running command: {e}")
47
+ print(f"stdout: {e.stdout}")
48
+ print(f"stderr: {e.stderr}")
49
+ raise e
50
+
51
+
52
+ class Command:
53
+ nop: "Command" = None
54
+
55
+ def isNop(self):
56
+ return self == Command.nop
57
+
58
+ def __init__(
59
+ self, command: List[str], stdin: Optional[str] = None, log: bool = True, id=None
60
+ ):
61
+ """
62
+ Initialize a Command object.
63
+
64
+ Args:
65
+ command: The command to run as a list of strings.
66
+ stdin: Input to pass to the command via stdin.
67
+ log: If True, log the command before executing it.
68
+ """
69
+ self.command = command
70
+ self.stdin = stdin
71
+ self.log = log
72
+ self.id = id
73
+
74
+ def execute(self, log: Optional[bool] = None) -> str:
75
+ """
76
+ Execute the command and return its output.
77
+
78
+ Args:
79
+ log: If provided, overrides the log setting from the constructor.
80
+
81
+ Returns:
82
+ The stdout of the command as a string.
83
+ """
84
+ if not self.command:
85
+ return
86
+ # Use the provided log value if available, otherwise use the one from the constructor
87
+ use_log = log if log is not None else self.log
88
+ if not self.stdin:
89
+ subprocess.run(self.command)
90
+ return run_cli_command(self.command, stdin=self.stdin, log=use_log, shell=False)
91
+
92
+ def __str__(self) -> str:
93
+ """
94
+ Return a string representation of the command.
95
+
96
+ Returns:
97
+ A string representation of the command, including stdin if applicable.
98
+ """
99
+ if self.stdin:
100
+ return f"echo '{self.stdin}' | {' '.join(self.command)}"
101
+ elif self.command:
102
+ return " ".join(self.command)
103
+ else:
104
+ return "NOP"
105
+
106
+
107
+ Command.nop = Command([])
@@ -0,0 +1,52 @@
1
+ import os
2
+ import sys
3
+
4
+
5
+ def merge_files_from_directories(directories):
6
+ merged_content = []
7
+
8
+ for path in directories:
9
+ if os.path.isdir(path):
10
+ # If the path is a directory, read .conf files from it
11
+ for filename in os.listdir(path):
12
+ if filename.endswith(".conf"): # Only consider .conf files
13
+ filepath = os.path.join(path, filename)
14
+ with open(filepath, "r") as file:
15
+ content = (
16
+ file.read().strip()
17
+ ) # Strip leading/trailing whitespace
18
+ if content: # Add only non-empty content
19
+ # Add directory and filename as a comment at the start of the content
20
+ merged_content.append(f"# {path}/{filename}\n{content}")
21
+ elif os.path.isfile(path) and path.endswith(".conf"):
22
+ # If the path is a .conf file, read its content
23
+ with open(path, "r") as file:
24
+ content = file.read().strip() # Strip leading/trailing whitespace
25
+ if content: # Add only non-empty content
26
+ # Add the file name as a comment at the start of the content
27
+ merged_content.append(f"# {path}\n{content}")
28
+ else:
29
+ print(
30
+ f"Warning: '{path}' is not a valid directory or .conf file. Skipping."
31
+ )
32
+
33
+ # Join the content with a single newline between entries
34
+ result = "\n\n".join(merged_content)
35
+
36
+ # Strip extra empty lines from the beginning and end
37
+ result = result.strip()
38
+
39
+ # Print the output to stdout
40
+ print(result)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ # Take directories or .conf files from command-line arguments
45
+ if len(sys.argv) < 2:
46
+ print(
47
+ "Usage: python merge_files.py <directory1|file1.conf> <directory2|file2.conf> ..."
48
+ )
49
+ sys.exit(1)
50
+
51
+ paths_to_read = sys.argv[1:]
52
+ merge_files_from_directories(paths_to_read)
@@ -0,0 +1,150 @@
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])
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.1
2
+ Name: docker-stack
3
+ Version: 0.1.1
4
+ Summary: CLI for deploying and managing Docker stacks.
5
+ Home-page: https://github.com/mesuidp/docker-stack
6
+ Author: Sudip Bhattarai
7
+ Author-email: sudip.dev.np@gmail.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.6
12
+ Description-Content-Type: text/markdown
13
+
14
+ Docker Stack
15
+ ==============
16
+ cli utility for stack deployment in docker-swarm.
17
+ #### Features
18
+ - docker config and secret creation and versioning
19
+ - docker stack versioning and config backup for rollback
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ docker_stack/__init__.py
5
+ docker_stack/cli.py
6
+ docker_stack/compose.py
7
+ docker_stack/docker_objects.py
8
+ docker_stack/envsubst.py
9
+ docker_stack/envsubst_merge.py
10
+ docker_stack/helpers.py
11
+ docker_stack/merge_conf.py
12
+ docker_stack/registry.py
13
+ docker_stack.egg-info/PKG-INFO
14
+ docker_stack.egg-info/SOURCES.txt
15
+ docker_stack.egg-info/dependency_links.txt
16
+ docker_stack.egg-info/entry_points.txt
17
+ docker_stack.egg-info/requires.txt
18
+ docker_stack.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docker-stack = docker_stack.cli:main
@@ -0,0 +1 @@
1
+ docker_stack
@@ -0,0 +1,5 @@
1
+ [tool.pytest.ini_options]
2
+ testpaths = [
3
+ "tests",
4
+ ]
5
+ pythonpath = ["docker_stack", "."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,27 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="docker-stack",
5
+ version="0.1.1",
6
+ description="CLI for deploying and managing Docker stacks.",
7
+ long_description=open("README.md").read(), # You can include a README file to describe your package
8
+ long_description_content_type="text/markdown",
9
+ author="Sudip Bhattarai",
10
+ author_email="sudip.dev.np@gmail.com",
11
+ url="https://github.com/mesuidp/docker-stack", # Replace with your project URL
12
+ packages=find_packages(),
13
+ install_requires=[
14
+ "PyYAML"
15
+ ],
16
+ entry_points={
17
+ "console_scripts": [
18
+ "docker-stack=docker_stack.cli:main", # The function main() inside docker_stack.cli module
19
+ ],
20
+ },
21
+ classifiers=[
22
+ "Programming Language :: Python :: 3",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Operating System :: OS Independent",
25
+ ],
26
+ python_requires=">=3.6",
27
+ )