cluster-builder 0.1.1__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.1 → 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.1.1 → cluster_builder-0.2.0}/cluster_builder/swarmchestrate.py +42 -24
- 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.1 → 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.1 → cluster_builder-0.2.0}/pyproject.toml +4 -3
- cluster_builder-0.1.1/cluster_builder.egg-info/SOURCES.txt +0 -11
- {cluster_builder-0.1.1 → cluster_builder-0.2.0}/LICENSE +0 -0
- {cluster_builder-0.1.1 → cluster_builder-0.2.0}/README.md +0 -0
- {cluster_builder-0.1.1 → cluster_builder-0.2.0}/cluster_builder/__init__.py +0 -0
- {cluster_builder-0.1.1 → cluster_builder-0.2.0}/cluster_builder.egg-info/dependency_links.txt +0 -0
- {cluster_builder-0.1.1 → cluster_builder-0.2.0}/cluster_builder.egg-info/requires.txt +0 -0
- {cluster_builder-0.1.1 → cluster_builder-0.2.0}/cluster_builder.egg-info/top_level.txt +0 -0
- {cluster_builder-0.1.1 → cluster_builder-0.2.0}/setup.cfg +0 -0
- {cluster_builder-0.1.1 → 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
|
|
@@ -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
|
|
80
|
+
def validate_configuration(self, cloud: str, config: dict) -> list:
|
|
83
81
|
"""
|
|
84
|
-
Validate
|
|
82
|
+
Validate a configuration against the required variables for a cloud provider.
|
|
85
83
|
|
|
86
84
|
Args:
|
|
87
|
-
|
|
85
|
+
cloud: Cloud provider name
|
|
86
|
+
config: Configuration dictionary provided by the user
|
|
88
87
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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(
|
|
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"
|
|
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,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
|
{cluster_builder-0.1.1 → 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
|