cluster-builder 0.1.1__py3-none-any.whl → 0.2.0__py3-none-any.whl

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.

Potentially problematic release.


This version of cluster-builder might be problematic. Click here for more details.

@@ -0,0 +1,8 @@
1
+ """
2
+ Configuration management for the Cluster Builder.
3
+ """
4
+
5
+ from cluster_builder.config.postgres import PostgresConfig
6
+ from cluster_builder.config.cluster import ClusterConfig
7
+
8
+ __all__ = ["PostgresConfig", "ClusterConfig"]
@@ -0,0 +1,125 @@
1
+ """
2
+ Cluster configuration management.
3
+ """
4
+
5
+ import os
6
+ import logging
7
+
8
+ from names_generator import generate_name
9
+
10
+ from cluster_builder.templates.manager import TemplateManager
11
+
12
+ logger = logging.getLogger("swarmchestrate")
13
+
14
+
15
+ class ClusterConfig:
16
+ """Manages cluster configuration and preparation."""
17
+
18
+ def __init__(
19
+ self,
20
+ template_manager: TemplateManager,
21
+ output_dir: str,
22
+ ):
23
+ """
24
+ Initialise the ClusterConfig.
25
+
26
+ Args:
27
+ template_manager: Template manager instance
28
+ output_dir: Directory for output files
29
+ """
30
+ self.template_manager = template_manager
31
+ self.output_dir = output_dir
32
+
33
+ def get_cluster_output_dir(self, cluster_name: str) -> str:
34
+ """
35
+ Get the output directory path for a specific cluster.
36
+
37
+ Args:
38
+ cluster_name: Name of the cluster
39
+
40
+ Returns:
41
+ Path to the cluster output directory
42
+ """
43
+ return os.path.join(self.output_dir, f"cluster_{cluster_name}")
44
+
45
+ def generate_random_name(self) -> str:
46
+ """
47
+ Generate a readable random string using names-generator.
48
+
49
+ Returns:
50
+ A randomly generated name
51
+ """
52
+ name = generate_name()
53
+ logger.debug(f"Generated random name: {name}")
54
+ return name
55
+
56
+ def prepare(self, config: dict[str, any]) -> tuple[str, dict[str, any]]:
57
+ """
58
+ Prepare the configuration and template files for deployment.
59
+
60
+ Args:
61
+ config: Configuration dictionary containing cloud, k3s_role, and
62
+ optionally cluster_name
63
+
64
+ Returns:
65
+ Tuple containing the cluster directory path and updated configuration
66
+
67
+ Raises:
68
+ ValueError: If required configuration is missing
69
+ RuntimeError: If file operations fail
70
+ """
71
+ # Validate required configuration
72
+ if "cloud" not in config:
73
+ error_msg = "Cloud provider must be specified in configuration"
74
+ logger.error(error_msg)
75
+ raise ValueError(error_msg)
76
+
77
+ if "k3s_role" not in config:
78
+ error_msg = "K3s role must be specified in configuration"
79
+ logger.error(error_msg)
80
+ raise ValueError(error_msg)
81
+
82
+ # Create a copy of the configuration
83
+ prepared_config = config.copy()
84
+
85
+ cloud = prepared_config["cloud"]
86
+ role = prepared_config["k3s_role"]
87
+ logger.info(f"Preparing configuration for cloud={cloud}, role={role}")
88
+
89
+ # Set module source path
90
+ prepared_config["module_source"] = self.template_manager.get_module_source_path(
91
+ cloud
92
+ )
93
+ logger.debug(f"Using module source: {prepared_config['module_source']}")
94
+
95
+ # Generate a cluster name if not provided
96
+ if "cluster_name" not in prepared_config:
97
+ cluster_name = self.generate_random_name()
98
+ prepared_config["cluster_name"] = cluster_name
99
+ logger.info(f"Generated cluster name: {cluster_name}")
100
+ else:
101
+ logger.info(
102
+ f"Using provided cluster name: {prepared_config['cluster_name']}"
103
+ )
104
+
105
+ cluster_dir = self.get_cluster_output_dir(prepared_config["cluster_name"])
106
+ logger.debug(f"Cluster directory: {cluster_dir}")
107
+
108
+ # Generate a resource name
109
+ random_name = self.generate_random_name()
110
+ prepared_config["resource_name"] = f"{cloud}_{random_name}"
111
+ logger.debug(f"Resource name: {prepared_config['resource_name']}")
112
+
113
+ # Create the cluster directory
114
+ try:
115
+ os.makedirs(cluster_dir, exist_ok=True)
116
+ logger.debug(f"Created directory: {cluster_dir}")
117
+ except OSError as e:
118
+ error_msg = f"Failed to create directory {cluster_dir}: {e}"
119
+ logger.error(error_msg)
120
+ raise RuntimeError(error_msg)
121
+
122
+ # Copy user data template
123
+ self.template_manager.copy_user_data_template(role, cloud)
124
+
125
+ return cluster_dir, prepared_config
@@ -0,0 +1,99 @@
1
+ """
2
+ PostgreSQL configuration for Terraform state backend.
3
+ """
4
+
5
+ import os
6
+ import logging
7
+ from dataclasses import dataclass
8
+
9
+ logger = logging.getLogger("swarmchestrate")
10
+
11
+
12
+ @dataclass
13
+ class PostgresConfig:
14
+ """Configuration for PostgreSQL backend."""
15
+
16
+ user: str
17
+ password: str
18
+ host: str
19
+ database: str
20
+ sslmode: str = "prefer"
21
+
22
+ @classmethod
23
+ def from_dict(cls, config: dict[str, str]) -> "PostgresConfig":
24
+ """
25
+ Create a PostgresConfig instance from a dictionary.
26
+
27
+ Args:
28
+ config: Dictionary containing PostgreSQL configuration
29
+
30
+ Returns:
31
+ PostgresConfig instance
32
+
33
+ Raises:
34
+ ValueError: If required configuration is missing
35
+ """
36
+ required_keys = ["user", "password", "host", "database"]
37
+ missing_keys = [key for key in required_keys if key not in config]
38
+
39
+ if missing_keys:
40
+ raise ValueError(
41
+ f"Missing required PostgreSQL configuration: {', '.join(missing_keys)}"
42
+ )
43
+
44
+ return cls(
45
+ user=config["user"],
46
+ password=config["password"],
47
+ host=config["host"],
48
+ database=config["database"],
49
+ sslmode=config.get("sslmode", "prefer"),
50
+ )
51
+
52
+ @classmethod
53
+ def from_env(cls) -> "PostgresConfig":
54
+ """
55
+ Create a PostgresConfig instance from environment variables.
56
+
57
+ Environment variables used:
58
+ - POSTGRES_USER
59
+ - POSTGRES_PASSWORD
60
+ - POSTGRES_HOST
61
+ - POSTGRES_DATABASE
62
+ - POSTGRES_SSLMODE (optional, defaults to 'prefer')
63
+
64
+ Returns:
65
+ PostgresConfig instance
66
+
67
+ Raises:
68
+ ValueError: If required environment variables are missing
69
+ """
70
+ # Check for required environment variables
71
+ required_vars = [
72
+ "POSTGRES_USER",
73
+ "POSTGRES_PASSWORD",
74
+ "POSTGRES_HOST",
75
+ "POSTGRES_DATABASE",
76
+ ]
77
+ missing_vars = [var for var in required_vars if not os.environ.get(var)]
78
+
79
+ if missing_vars:
80
+ raise ValueError(
81
+ f"Missing required PostgreSQL environment variables: {', '.join(missing_vars)}"
82
+ )
83
+
84
+ # Create config from environment variables
85
+ return cls(
86
+ user=os.environ["POSTGRES_USER"],
87
+ password=os.environ["POSTGRES_PASSWORD"],
88
+ host=os.environ["POSTGRES_HOST"],
89
+ database=os.environ["POSTGRES_DATABASE"],
90
+ sslmode=os.environ.get("POSTGRES_SSLMODE", "prefer"),
91
+ )
92
+
93
+ def get_connection_string(self) -> str:
94
+ """Generate a PostgreSQL connection string from the configuration."""
95
+ return (
96
+ f"postgres://{self.user}:{self.password}@"
97
+ f"{self.host}:5432/{self.database}?"
98
+ f"sslmode={self.sslmode}"
99
+ )
@@ -0,0 +1,7 @@
1
+ """
2
+ Infrastructure management for the Cluster Builder.
3
+ """
4
+
5
+ from cluster_builder.infrastructure.executor import CommandExecutor
6
+
7
+ __all__ = ["CommandExecutor"]
@@ -0,0 +1,88 @@
1
+ """
2
+ Command execution utilities for infrastructure management.
3
+ """
4
+
5
+ import subprocess
6
+ import logging
7
+
8
+ logger = logging.getLogger("swarmchestrate")
9
+
10
+
11
+ class CommandExecutor:
12
+ """Utility for executing shell commands with proper logging and error handling."""
13
+
14
+ @staticmethod
15
+ def run_command(
16
+ command: list, cwd: str, description: str = "command", timeout: int = None
17
+ ) -> str:
18
+ """
19
+ Execute a shell command with proper logging and error handling.
20
+
21
+ Args:
22
+ command: List containing the command and its arguments
23
+ cwd: Working directory for the command
24
+ description: Description of the command for logging
25
+ timeout: Maximum execution time in seconds (None for no timeout)
26
+
27
+ Returns:
28
+ Command stdout output as string
29
+
30
+ Raises:
31
+ RuntimeError: If the command execution fails or times out
32
+ """
33
+ cmd_str = " ".join(command)
34
+ logger.info(f"Running {description}: {cmd_str}")
35
+
36
+ try:
37
+ # Start the process using Popen
38
+ process = subprocess.Popen(
39
+ command,
40
+ cwd=cwd,
41
+ stdout=subprocess.PIPE,
42
+ stderr=subprocess.PIPE,
43
+ text=True,
44
+ )
45
+
46
+ # Wait for the process with timeout
47
+ try:
48
+ stdout, stderr = process.communicate(timeout=timeout)
49
+
50
+ # Check if the process was successful
51
+ if process.returncode != 0:
52
+ error_msg = f"Error executing {description}: {stderr}"
53
+ logger.error(error_msg)
54
+ raise RuntimeError(error_msg)
55
+
56
+ logger.debug(f"{description.capitalize()} output: {stdout}")
57
+ return stdout
58
+
59
+ except subprocess.TimeoutExpired:
60
+ # Process timed out - try to get any output so far
61
+ # Kill the process
62
+ process.kill()
63
+
64
+ # Capture any output that was generated before the timeout
65
+ stdout, stderr = process.communicate()
66
+
67
+ # Print and log the captured output
68
+ print(f"\n--- {description.capitalize()} stdout before timeout ---")
69
+ print(stdout)
70
+ print(f"\n--- {description.capitalize()} stderr before timeout ---")
71
+ print(stderr)
72
+
73
+ error_msg = (
74
+ f"{description.capitalize()} timed out after {timeout} seconds"
75
+ )
76
+ logger.error(error_msg)
77
+ raise RuntimeError(error_msg) from None
78
+
79
+ except subprocess.CalledProcessError as e:
80
+ error_msg = f"Error executing {description}: {e.stderr}"
81
+ logger.error(error_msg)
82
+ raise RuntimeError(error_msg)
83
+ except Exception as e:
84
+ if not isinstance(e, RuntimeError): # Avoid re-wrapping our own exceptions
85
+ error_msg = f"Unexpected error during {description}: {str(e)}"
86
+ logger.error(error_msg)
87
+ raise RuntimeError(error_msg)
88
+ raise
@@ -50,9 +50,7 @@ class Swarmchestrate:
50
50
 
51
51
  # Initialise components
52
52
  self.template_manager = TemplateManager()
53
- self.cluster_config = ClusterConfig(
54
- self.template_manager, output_dir, self.pg_config
55
- )
53
+ self.cluster_config = ClusterConfig(self.template_manager, output_dir)
56
54
 
57
55
  logger.info(
58
56
  f"Initialised with template_dir={template_dir}, output_dir={output_dir}"
@@ -79,23 +77,17 @@ class Swarmchestrate:
79
77
  """
80
78
  return self.cluster_config.generate_random_name()
81
79
 
82
- def _validate_node_config(self, config: dict[str, any]) -> None:
80
+ def validate_configuration(self, cloud: str, config: dict) -> list:
83
81
  """
84
- Validate node configuration.
82
+ Validate a configuration against the required variables for a cloud provider.
85
83
 
86
84
  Args:
87
- config: Configuration dictionary
85
+ cloud: Cloud provider name
86
+ config: Configuration dictionary provided by the user
88
87
 
89
- Raises:
90
- ValueError: If configuration is invalid
88
+ Returns:
89
+ List of missing required variables (empty if all required variables are present)
91
90
  """
92
- # Check required fields
93
- if "cloud" not in config:
94
- raise ValueError("Cloud provider must be specified in configuration")
95
-
96
- if "k3s_role" not in config:
97
- raise ValueError("K3s role must be specified in configuration")
98
-
99
91
  # Master IP validation
100
92
  has_master_ip = "master_ip" in config and config["master_ip"]
101
93
  role = config["k3s_role"]
@@ -103,13 +95,24 @@ class Swarmchestrate:
103
95
  # Cannot add a master node to an existing cluster
104
96
  if has_master_ip and role == "master":
105
97
  raise ValueError(
106
- "Cannot add a master node to an existing cluster (master_ip specified with master role)"
98
+ "Cannot add master to existing cluster (master_ip specified with master role)"
107
99
  )
108
100
 
109
101
  # Worker/HA nodes require a master IP
110
102
  if not has_master_ip and role in ["worker", "ha"]:
111
103
  raise ValueError(f"Role '{role}' requires master_ip to be specified")
112
104
 
105
+ required_vars = self.template_manager.get_required_variables(cloud)
106
+
107
+ # Find missing required variables
108
+ missing_vars = []
109
+ for var_name, var_config in required_vars.items():
110
+ # If variable has no default and is not in config, it's required but missing
111
+ if "default" not in var_config and var_name not in config:
112
+ missing_vars.append(var_name)
113
+
114
+ return missing_vars
115
+
113
116
  def prepare_infrastructure(
114
117
  self, config: dict[str, any]
115
118
  ) -> tuple[str, dict[str, any]]:
@@ -131,14 +134,18 @@ class Swarmchestrate:
131
134
  RuntimeError: If file operations fail
132
135
  """
133
136
  try:
134
- # Validate the configuration
135
- self._validate_node_config(config)
136
-
137
- # Prepare the configuration and files
137
+ # Prepare the configuration
138
138
  cluster_dir, prepared_config = self.cluster_config.prepare(config)
139
139
 
140
+ # Validate the configuration
141
+ cloud = prepared_config["cloud"]
142
+ missing_vars = self.validate_configuration(cloud, prepared_config)
143
+ if missing_vars:
144
+ raise ValueError(
145
+ f"Missing required variables for cloud provider '{cloud}': {', '.join(missing_vars)}"
146
+ )
147
+
140
148
  # Create provider configuration
141
- cloud = config["cloud"]
142
149
  self.template_manager.create_provider_config(cluster_dir, cloud)
143
150
  logger.info(f"Created provider configuration for {cloud}")
144
151
 
@@ -147,9 +154,12 @@ class Swarmchestrate:
147
154
  backend_tf_path = os.path.join(cluster_dir, "backend.tf")
148
155
 
149
156
  # Add backend configuration
157
+
158
+ # Add PostgreSQL connection string to config
159
+ conn_str = self.pg_config.get_connection_string()
150
160
  hcl.add_backend_config(
151
161
  backend_tf_path,
152
- prepared_config["pg_conn_str"],
162
+ conn_str,
153
163
  prepared_config["cluster_name"],
154
164
  )
155
165
  logger.info(f"Added backend configuration to {backend_tf_path}")
@@ -294,7 +304,12 @@ class Swarmchestrate:
294
304
  return
295
305
 
296
306
  # Plan the deployment
297
- CommandExecutor.run_command(["tofu", "plan"], cluster_dir, "OpenTofu plan")
307
+ CommandExecutor.run_command(
308
+ ["tofu", "plan", "-input=false"],
309
+ cluster_dir,
310
+ "OpenTofu plan",
311
+ timeout=30,
312
+ )
298
313
 
299
314
  # Apply the deployment
300
315
  CommandExecutor.run_command(
@@ -335,7 +350,10 @@ class Swarmchestrate:
335
350
  try:
336
351
  # Plan destruction
337
352
  CommandExecutor.run_command(
338
- ["tofu", "plan", "-destroy"], cluster_dir, "OpenTofu plan destruction"
353
+ ["tofu", "plan", "-destroy", "-input=false"],
354
+ cluster_dir,
355
+ "OpenTofu plan destruction",
356
+ timeout=30,
339
357
  )
340
358
 
341
359
  # Execute destruction
@@ -0,0 +1,7 @@
1
+ """
2
+ Template management for the Cluster Builder.
3
+ """
4
+
5
+ from cluster_builder.templates.manager import TemplateManager
6
+
7
+ __all__ = ["TemplateManager"]
@@ -0,0 +1,118 @@
1
+ """
2
+ Template management for cluster deployments.
3
+ """
4
+
5
+ import os
6
+ import shutil
7
+ import logging
8
+
9
+ from cluster_builder.utils.hcl import extract_template_variables
10
+
11
+ logger = logging.getLogger("swarmchestrate")
12
+
13
+
14
+ class TemplateManager:
15
+ """Manages template files and operations for cluster deployment."""
16
+
17
+ def __init__(self):
18
+ """Initialise the TemplateManager."""
19
+ current_dir = os.path.dirname(os.path.abspath(__file__)) # templates directory
20
+ self.base_dir = os.path.dirname(
21
+ os.path.dirname(current_dir)
22
+ ) # Go up two levels
23
+ self.templates_dir = os.path.join(self.base_dir, "templates")
24
+ logger.debug(
25
+ f"Initialised TemplateManager with templates_dir={self.templates_dir}"
26
+ )
27
+
28
+ def get_module_source_path(self, cloud: str) -> str:
29
+ """
30
+ Get the module source path for a specific cloud provider.
31
+
32
+ Args:
33
+ cloud: Cloud provider name
34
+
35
+ Returns:
36
+ Path to the module source directory
37
+ """
38
+ return f"{self.templates_dir}/{cloud}/"
39
+
40
+ def create_provider_config(self, cluster_dir: str, cloud: str) -> None:
41
+ """
42
+ Create provider configuration file for a specific cloud provider.
43
+
44
+ Args:
45
+ cluster_dir: Directory for the cluster
46
+ cloud: Cloud provider (e.g., 'aws')
47
+
48
+ Raises:
49
+ ValueError: If provider template is not found
50
+ """
51
+ # Define the path for provider config in templates directory
52
+ provider_template_path = os.path.join(
53
+ self.templates_dir, f"{cloud.lower()}_provider.tf"
54
+ )
55
+
56
+ # Check if template exists
57
+ if not os.path.exists(provider_template_path):
58
+ error_msg = f"Provider template not found: {provider_template_path}"
59
+ logger.error(error_msg)
60
+ raise ValueError(
61
+ f"Provider template for cloud '{cloud}' not found. Expected at: {provider_template_path}"
62
+ )
63
+
64
+ # Target file in cluster directory
65
+ provider_file = os.path.join(cluster_dir, f"{cloud.lower()}_provider.tf")
66
+
67
+ try:
68
+ # Simply copy the provider config file to the cluster directory
69
+ shutil.copy2(provider_template_path, provider_file)
70
+ logger.info(f"Created {cloud} provider configuration at {provider_file}")
71
+ except Exception as e:
72
+ error_msg = f"Failed to create provider configuration: {e}"
73
+ logger.error(error_msg)
74
+ raise RuntimeError(error_msg)
75
+
76
+ def copy_user_data_template(self, role: str, cloud: str) -> None:
77
+ """
78
+ Copy the user data template for a specific role to the cloud provider directory.
79
+
80
+ Args:
81
+ role: K3s role (master, worker, etc.)
82
+ cloud: Cloud provider name
83
+
84
+ Raises:
85
+ RuntimeError: If the template file doesn't exist or can't be copied
86
+ """
87
+ user_data_src = os.path.join(self.templates_dir, f"{role}_user_data.sh.tpl")
88
+ user_data_dst = os.path.join(
89
+ self.templates_dir, cloud, f"{role}_user_data.sh.tpl"
90
+ )
91
+
92
+ if not os.path.exists(user_data_src):
93
+ error_msg = f"User data template not found: {user_data_src}"
94
+ logger.error(error_msg)
95
+ raise RuntimeError(error_msg)
96
+
97
+ try:
98
+ shutil.copy2(user_data_src, user_data_dst)
99
+ logger.info(
100
+ f"Copied user data template from {user_data_src} to {user_data_dst}"
101
+ )
102
+ except (OSError, shutil.Error) as e:
103
+ error_msg = f"Failed to copy user data template: {e}"
104
+ logger.error(error_msg)
105
+ raise RuntimeError(error_msg)
106
+
107
+ def get_required_variables(self, cloud: str) -> dict:
108
+ """
109
+ Get the variables required for a specific cloud provider's templates.
110
+
111
+ Args:
112
+ cloud: Cloud provider name (e.g., 'aws')
113
+
114
+ Returns:
115
+ Dictionary of variable names to their configurations
116
+ """
117
+ template_path = os.path.join(self.templates_dir, cloud, "main.tf")
118
+ return extract_template_variables(template_path)
@@ -0,0 +1,7 @@
1
+ """
2
+ Utility functions for the Cluster Builder.
3
+ """
4
+
5
+ from cluster_builder.utils.logging import configure_logging
6
+
7
+ __all__ = ["configure_logging"]
@@ -0,0 +1,231 @@
1
+ import os
2
+ import hcl2
3
+ from lark import Tree, Token
4
+
5
+
6
+ def add_backend_config(backend_tf_path, conn_str, schema_name):
7
+ """
8
+ Adds a PostgreSQL backend configuration to a Terraform file.
9
+ - `backend_tf_path`: path to backend.tf for this configuration
10
+ - `conn_str`: PostgreSQL connection string
11
+ - `schema_name`: Schema name for Terraform state
12
+ """
13
+ # Check if the backend configuration already exists
14
+ if os.path.exists(backend_tf_path):
15
+ with open(backend_tf_path) as f:
16
+ if 'backend "pg"' in f.read():
17
+ print("⚠️ Backend configuration already exists — skipping.")
18
+ return
19
+
20
+ # Build the backend configuration block
21
+ lines = [
22
+ "terraform {",
23
+ ' backend "pg" {',
24
+ f' conn_str = "{conn_str}"',
25
+ f' schema_name = "{schema_name}"',
26
+ " }",
27
+ "}",
28
+ ]
29
+
30
+ # Write to backend.tf
31
+ os.makedirs(os.path.dirname(backend_tf_path), exist_ok=True)
32
+ with open(
33
+ backend_tf_path, "w"
34
+ ) as f: # Use "w" instead of "a" to create/overwrite the file
35
+ f.write("\n".join(lines) + "\n")
36
+
37
+ print(f"✅ Added PostgreSQL backend configuration to {backend_tf_path}")
38
+
39
+
40
+ def add_module_block(main_tf_path, module_name, config):
41
+ """
42
+ Appends a new module block to main.tf for this RA+cluster.
43
+ - `main_tf_path`: path to `main.tf` for this RA+cluster
44
+ - `module_name`: e.g. "master_xyz123"
45
+ - `config`: dict of configuration and module-specific variables
46
+ """
47
+ # Check if the module already exists
48
+ if os.path.exists(main_tf_path):
49
+ with open(main_tf_path) as f:
50
+ if f'module "{module_name}"' in f.read():
51
+ print(f"⚠️ Module '{module_name}' already exists — skipping.")
52
+ return
53
+
54
+ # Build the module block
55
+ lines = [f'module "{module_name}" {{', f' source = "{config["module_source"]}"']
56
+ for k, v in config.items():
57
+ if k == "module_source":
58
+ continue # Skip the module source since it's already handled
59
+ if isinstance(v, bool):
60
+ v_str = "true" if v else "false"
61
+ elif isinstance(v, (int, float)):
62
+ v_str = str(v)
63
+ elif v is None:
64
+ continue
65
+ else:
66
+ v_str = f'"{v}"'
67
+ lines.append(f" {k} = {v_str}")
68
+ lines.append("}")
69
+
70
+ # Write to main.tf
71
+ with open(main_tf_path, "a") as f:
72
+ f.write("\n\n" + "\n".join(lines) + "\n")
73
+
74
+ print(f"✅ Added module '{module_name}' to {main_tf_path}")
75
+
76
+
77
+ def is_target_module_block(tree: Tree, module_name: str) -> bool:
78
+ """
79
+ Check if the tree is a module block with the specified name.
80
+ """
81
+ if tree.data != "block":
82
+ return False
83
+
84
+ # Need at least 3 children: identifier, name, body
85
+ if len(tree.children) < 3:
86
+ return False
87
+
88
+ # First child should be an identifier tree
89
+ first_child = tree.children[0]
90
+ if not isinstance(first_child, Tree) or first_child.data != "identifier":
91
+ return False
92
+
93
+ # First child should have a NAME token with 'module'
94
+ if len(first_child.children) == 0 or not isinstance(first_child.children[0], Token):
95
+ return False
96
+
97
+ if first_child.children[0].value != "module":
98
+ return False
99
+
100
+ # Second child should be a STRING_LIT token with module name
101
+ second_child = tree.children[1]
102
+ if not isinstance(second_child, Token) or second_child.value != f'"{module_name}"':
103
+ return False
104
+
105
+ return True
106
+
107
+
108
+ def simple_remove_module(tree, module_name, removed=False):
109
+ """
110
+ A simpler function to remove module blocks that maintains the exact Tree structure
111
+ that the write function expects.
112
+ """
113
+ # Don't remove the root node
114
+ if tree.data == "start":
115
+ # Process only the body of the start rule
116
+ body_node = tree.children[0]
117
+
118
+ if isinstance(body_node, Tree) and body_node.data == "body":
119
+ # Create new children list for the body node
120
+ new_body_children = []
121
+ skip_next = False
122
+
123
+ # Process body children (these should be blocks and new_line_or_comment nodes)
124
+ for i, child in enumerate(body_node.children):
125
+ if skip_next:
126
+ skip_next = False
127
+ continue
128
+
129
+ # If this is a block node, check if it's our target
130
+ if (
131
+ isinstance(child, Tree)
132
+ and child.data == "block"
133
+ and is_target_module_block(child, module_name)
134
+ ):
135
+ removed = True
136
+
137
+ # Check if the next node is a new_line_or_comment, and skip it as well
138
+ if i + 1 < len(body_node.children):
139
+ next_child = body_node.children[i + 1]
140
+ if (
141
+ isinstance(next_child, Tree)
142
+ and next_child.data == "new_line_or_comment"
143
+ ):
144
+ skip_next = True
145
+ else:
146
+ new_body_children.append(child)
147
+
148
+ # Replace body children with filtered list
149
+ new_body = Tree(body_node.data, new_body_children)
150
+ return Tree(tree.data, [new_body]), removed
151
+
152
+ # No changes made
153
+ return tree, removed
154
+
155
+
156
+ def remove_module_block(main_tf_path, module_name: str):
157
+ """
158
+ Removes a module block by name from main.tf for this cluster.
159
+ """
160
+ if not os.path.exists(main_tf_path):
161
+ print(f"⚠️ No main.tf found at {main_tf_path}")
162
+ return
163
+
164
+ try:
165
+ with open(main_tf_path, "r") as f:
166
+ tree = hcl2.parse(f)
167
+ except Exception as e:
168
+ print(f"❌ Failed to parse HCL: {e}")
169
+ return
170
+
171
+ # Process tree to remove target module block
172
+ new_tree, removed = simple_remove_module(tree, module_name)
173
+
174
+ # If no modules were removed
175
+ if not removed:
176
+ print(f"⚠️ No module named '{module_name}' found in {main_tf_path}")
177
+ return
178
+
179
+ try:
180
+ # Reconstruct HCL
181
+ new_source = hcl2.writes(new_tree)
182
+
183
+ # Write back to file
184
+ with open(main_tf_path, "w") as f:
185
+ f.write(new_source)
186
+
187
+ print(f"🗑️ Removed module '{module_name}' from {main_tf_path}")
188
+ except Exception as e:
189
+ print(f"❌ Failed to reconstruct HCL: {e}")
190
+ # Print more detailed error information
191
+ import traceback
192
+
193
+ traceback.print_exc()
194
+
195
+
196
+ def extract_template_variables(template_path):
197
+ """
198
+ Extract variables from a Terraform template file using hcl2.
199
+
200
+ Args:
201
+ template_path: Path to the Terraform template file
202
+
203
+ Returns:
204
+ Dictionary of variable names to their complete configuration
205
+
206
+ Raises:
207
+ ValueError: If the template cannot be parsed or variables cannot be extracted
208
+ """
209
+ try:
210
+ with open(template_path, "r") as f:
211
+ parsed = hcl2.load(f)
212
+
213
+ variables = {}
214
+
215
+ # Extract variables from the list of variable blocks
216
+ if "variable" in parsed:
217
+ for var_block in parsed["variable"]:
218
+ # Each var_block is a dict with a single key (the variable name)
219
+ for var_name, var_config in var_block.items():
220
+ variables[var_name] = var_config
221
+
222
+ return variables
223
+
224
+ except FileNotFoundError:
225
+ print(f"Warning: Template file not found: {template_path}")
226
+ return {}
227
+
228
+ except Exception as e:
229
+ error_msg = f"Failed to extract variables from {template_path}: {e}"
230
+ print(f"Error: {error_msg}")
231
+ raise ValueError(error_msg)
@@ -0,0 +1,46 @@
1
+ """
2
+ Logging configuration for the cluster builder.
3
+ """
4
+
5
+ import logging
6
+ import sys
7
+ from typing import Optional
8
+
9
+
10
+ def configure_logging(
11
+ level: int = logging.INFO, log_file: Optional[str] = None
12
+ ) -> None:
13
+ """
14
+ Configure or reconfigure logging for the cluster builder.
15
+
16
+ Args:
17
+ level: Logging level (default: INFO)
18
+ log_file: Optional path to log file
19
+ """
20
+ # Root logger configuration
21
+ root_logger = logging.getLogger()
22
+ root_logger.setLevel(level)
23
+
24
+ # Clear existing handlers to avoid duplicates
25
+ for handler in root_logger.handlers[:]:
26
+ root_logger.removeHandler(handler)
27
+
28
+ # Create formatter
29
+ formatter = logging.Formatter(
30
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
31
+ )
32
+
33
+ # Console handler
34
+ console_handler = logging.StreamHandler(sys.stdout)
35
+ console_handler.setFormatter(formatter)
36
+ root_logger.addHandler(console_handler)
37
+
38
+ # File handler (if log_file is provided)
39
+ if log_file:
40
+ file_handler = logging.FileHandler(log_file)
41
+ file_handler.setFormatter(formatter)
42
+ root_logger.addHandler(file_handler)
43
+
44
+ # Configure swarmchestrate logger
45
+ logger = logging.getLogger("swarmchestrate")
46
+ logger.setLevel(level)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cluster-builder
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Swarmchestrate cluster builder
5
5
  Author-email: Gunjan <G.Kotak@westminster.ac.uk>, Jay <J.Deslauriers@westminster.ac.uk>
6
6
  License: Apache2
@@ -0,0 +1,17 @@
1
+ cluster_builder/__init__.py,sha256=p2Rb2BTVm-ScqCKE38436WsItY1BjVAnvx7zwmneSLs,256
2
+ cluster_builder/swarmchestrate.py,sha256=JZakXW_uEtp1AmsQj5gEHXPtqgaTZZCMeQ73eojA-Sw,13254
3
+ cluster_builder/config/__init__.py,sha256=HqCua7nqa0m4RNrH-wAw-GNZ8PfmKOeYs2Ur81xGIKU,222
4
+ cluster_builder/config/cluster.py,sha256=7EpDOjkOrLeakLXkHdJ0-RzZERG8tS-imnvm1ed9iYw,4034
5
+ cluster_builder/config/postgres.py,sha256=unrCox0x0037T7N1NJ_GXYZSvVBaOvHb_mSasHUQtHA,2852
6
+ cluster_builder/infrastructure/__init__.py,sha256=Trvz6h3xAuc6xL7PqO0D2yt-wtnIxIdl0QA6xMlCapM,159
7
+ cluster_builder/infrastructure/executor.py,sha256=PJuNFT-L6QlSTq37jVPgVAvsoT_GxSOuCOqYrvmjSlU,3113
8
+ cluster_builder/templates/__init__.py,sha256=H9Q6vzGeDMrWqdumXQsYeC8LjNVMQJ2rDDCjF5tptGQ,147
9
+ cluster_builder/templates/manager.py,sha256=x7P0Qi4uOKOtsTfdVcqW9bMF4Lj5v9-W1v5v1jHnRB8,4140
10
+ cluster_builder/utils/__init__.py,sha256=TeronqOND-SIfi0e76lwD1HfUiPO2h2ZfYhLIwZ3Aks,145
11
+ cluster_builder/utils/hcl.py,sha256=9PeZLTdWY0XspypiBYqYOJwjRmt7L8NlyqCNB2ymXVc,7611
12
+ cluster_builder/utils/logging.py,sha256=rwDViuqG8PMcXJWHOdtdgbGhWMnbSZ4MwfKsXHxu2B4,1242
13
+ cluster_builder-0.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
14
+ cluster_builder-0.2.0.dist-info/METADATA,sha256=2pEseraqIS_8kWvh-z0TUhevDquINHEZMRzJVSuGhm4,7563
15
+ cluster_builder-0.2.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
16
+ cluster_builder-0.2.0.dist-info/top_level.txt,sha256=fTW8EW1mcWoeWprjwxSHRWpqfXYX8iN-ByEt8HPXIcs,16
17
+ cluster_builder-0.2.0.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- cluster_builder/__init__.py,sha256=p2Rb2BTVm-ScqCKE38436WsItY1BjVAnvx7zwmneSLs,256
2
- cluster_builder/swarmchestrate.py,sha256=K3zWA4Ob6XVH0GDlDQW13Q3IHd1gEjHRvBfgtBLi-Wo,12471
3
- cluster_builder-0.1.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
4
- cluster_builder-0.1.1.dist-info/METADATA,sha256=-QMlTF4bk7QdDxT3og8NVIBigCv5QOMQ4v0EYJ4s7Ow,7563
5
- cluster_builder-0.1.1.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
6
- cluster_builder-0.1.1.dist-info/top_level.txt,sha256=fTW8EW1mcWoeWprjwxSHRWpqfXYX8iN-ByEt8HPXIcs,16
7
- cluster_builder-0.1.1.dist-info/RECORD,,