cluster-builder 0.1.2__tar.gz → 0.2.0__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.
Potentially problematic release.
This version of cluster-builder might be problematic. Click here for more details.
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/PKG-INFO +1 -1
- cluster_builder-0.2.0/cluster_builder/config/__init__.py +8 -0
- cluster_builder-0.2.0/cluster_builder/config/cluster.py +125 -0
- cluster_builder-0.2.0/cluster_builder/config/postgres.py +99 -0
- cluster_builder-0.2.0/cluster_builder/infrastructure/__init__.py +7 -0
- cluster_builder-0.2.0/cluster_builder/infrastructure/executor.py +88 -0
- cluster_builder-0.2.0/cluster_builder/templates/__init__.py +7 -0
- cluster_builder-0.2.0/cluster_builder/templates/manager.py +118 -0
- cluster_builder-0.2.0/cluster_builder/utils/__init__.py +7 -0
- cluster_builder-0.2.0/cluster_builder/utils/hcl.py +231 -0
- cluster_builder-0.2.0/cluster_builder/utils/logging.py +46 -0
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/cluster_builder.egg-info/PKG-INFO +1 -1
- cluster_builder-0.2.0/cluster_builder.egg-info/SOURCES.txt +21 -0
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/pyproject.toml +4 -3
- cluster_builder-0.1.2/cluster_builder.egg-info/SOURCES.txt +0 -11
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/LICENSE +0 -0
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/README.md +0 -0
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/cluster_builder/__init__.py +0 -0
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/cluster_builder/swarmchestrate.py +0 -0
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/cluster_builder.egg-info/dependency_links.txt +0 -0
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/cluster_builder.egg-info/requires.txt +0 -0
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/cluster_builder.egg-info/top_level.txt +0 -0
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/setup.cfg +0 -0
- {cluster_builder-0.1.2 → cluster_builder-0.2.0}/tests/test_hcl.py +0 -0
|
@@ -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,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
|
|
@@ -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,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)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
cluster_builder/__init__.py
|
|
5
|
+
cluster_builder/swarmchestrate.py
|
|
6
|
+
cluster_builder.egg-info/PKG-INFO
|
|
7
|
+
cluster_builder.egg-info/SOURCES.txt
|
|
8
|
+
cluster_builder.egg-info/dependency_links.txt
|
|
9
|
+
cluster_builder.egg-info/requires.txt
|
|
10
|
+
cluster_builder.egg-info/top_level.txt
|
|
11
|
+
cluster_builder/config/__init__.py
|
|
12
|
+
cluster_builder/config/cluster.py
|
|
13
|
+
cluster_builder/config/postgres.py
|
|
14
|
+
cluster_builder/infrastructure/__init__.py
|
|
15
|
+
cluster_builder/infrastructure/executor.py
|
|
16
|
+
cluster_builder/templates/__init__.py
|
|
17
|
+
cluster_builder/templates/manager.py
|
|
18
|
+
cluster_builder/utils/__init__.py
|
|
19
|
+
cluster_builder/utils/hcl.py
|
|
20
|
+
cluster_builder/utils/logging.py
|
|
21
|
+
tests/test_hcl.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cluster-builder"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Swarmchestrate cluster builder"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -21,5 +21,6 @@ dependencies = [
|
|
|
21
21
|
"python-dotenv"
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
-
[tool.setuptools]
|
|
25
|
-
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["."]
|
|
26
|
+
include = ["cluster_builder*"]
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
LICENSE
|
|
2
|
-
README.md
|
|
3
|
-
pyproject.toml
|
|
4
|
-
cluster_builder/__init__.py
|
|
5
|
-
cluster_builder/swarmchestrate.py
|
|
6
|
-
cluster_builder.egg-info/PKG-INFO
|
|
7
|
-
cluster_builder.egg-info/SOURCES.txt
|
|
8
|
-
cluster_builder.egg-info/dependency_links.txt
|
|
9
|
-
cluster_builder.egg-info/requires.txt
|
|
10
|
-
cluster_builder.egg-info/top_level.txt
|
|
11
|
-
tests/test_hcl.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cluster_builder-0.1.2 → cluster_builder-0.2.0}/cluster_builder.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|