Skip to content
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ClusterBloom
q# ClusterBloom
**ClusterBloom** is a tool for deploying and configuring Kubernetes clusters using RKE2, with specialized support for AMD GPU environments. It automates the process of setting up multi-node clusters, configuring storage with Longhorn, and integrating with various tools and services.


Expand Down Expand Up @@ -98,6 +98,9 @@ Cluster-Bloom can be configured through environment variables, command-line flag
| OIDC_URL | The URL of the OIDC provider | "" |
| RKE2_VERSION | Specific RKE2 version to install (e.g., "v1.34.1+rke2r1") | "" |
| SERVER_IP | The IP address of the RKE2 server (required for additional nodes) | |
| NODE_IP | The IP address to advertise when setting up additional node. Optional. | "" |
| NODE_EXTERNAL_IP | The external IP address to advertise for additional node. Optional. | "" |
| ADVERTISE_ADDRESS | IP address the apiserver uses to advertise to members of the cluster. Optional. | "" |
| SKIP_RANCHER_PARTITION_CHECK | Set to true to skip /var/lib/rancher partition size check | false |
| TLS_CERT | Path to TLS certificate file for ingress (required if CERT_OPTION is 'existing') | "" |
| TLS_KEY | Path to TLS private key file for ingress (required if CERT_OPTION is 'existing') | "" |
Expand Down
22 changes: 21 additions & 1 deletion cmd/arguments.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,33 @@ func SetArguments() {
Type: "non-empty-string",
Dependencies: "FIRST_NODE=true",
},
{
Key: "NODE_IP",
Default: "",
Description: "The IP address to advertise for this node. Optional.",
Type: "ip-address",
Dependencies: "FIRST_NODE=false",
},
{
Key: "NODE_EXTERNAL_IP",
Default: "",
Description: "The external IP address to advertise for this node. Optional.",
Type: "ip-address",
Dependencies: "FIRST_NODE=false",
},
{
Key: "ADVERTISE_ADDRESS",
Default: "",
Description: "The IP address the apiserver uses to advertise to members of the cluster. Optional.",
Type: "ip-address",
Dependencies: "FIRST_NODE=false",
},
{
Key: "CF_VALUES",
Default: "",
Description: "Path to ClusterForge values file (e.g., \"values_cf.yaml\"). Optional.",
Type: "string",
},

// TLS/Certificate configuration
{
Key: "USE_CERT_MANAGER",
Expand Down
43 changes: 43 additions & 0 deletions pkg/args/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,32 @@ func ValidateJoinTokenArg(token string) error {
return nil
}

func ValidateListOfHostnames(hostnames string) error {
if hostnames == "" {
return nil // Empty input is allowed
}

hostNameList := strings.Split(hostnames, ",")
for _, hostNameStr := range hostNameList {
hostNameStr = strings.TrimSpace(hostNameStr)
if hostNameStr == "" {
continue // Skip empty entries
}
NotValidIPErr := ValidateIPAddress(hostNameStr);
NotValidHostnameErr := ValidateHostname(hostNameStr);

if NotValidHostnameErr != nil && NotValidIPErr != nil {
if NotValidHostnameErr != nil {
return fmt.Errorf("invalid hostname in TLS SAN '%s': %v", hostNameStr, NotValidHostnameErr)
}
if NotValidIPErr != nil {
return fmt.Errorf("invalid IP address in TLS SAN '%s': %v", hostNameStr, NotValidIPErr)
}
}
}
return nil
}

// ValidateStepNamesArg validates that step names are valid against the steps from rootSteps
func ValidateStepNamesArg(stepNames string) error {
if stepNames == "" {
Expand Down Expand Up @@ -367,6 +393,23 @@ func ValidateURL(urlStr string) error {
return nil
}

// ValidateHostname validates a hostname
func ValidateHostname(hostnameStr string) error {
if hostnameStr == "" {
return nil // Empty hostnames are allowed for optional parameters
}

if strings.ToLower(hostnameStr) == "none" {
return nil
}

_, err := url.Parse(hostnameStr)
if err != nil {
return fmt.Errorf("invalid hostname format: %v", err)
}

return nil
}
// ValidateToken validates a token string (currently supports JOIN_TOKEN format)
func ValidateToken(token string) error {
return ValidateJoinTokenArg(token)
Expand Down
3 changes: 3 additions & 0 deletions pkg/args/args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ func init() {
{Key: "SERVER_IP", Default: "", Description: "IP address of the RKE2 server. Required for non-first nodes.", Type: "non-empty-ip-address", Dependencies: "FIRST_NODE=false"},
{Key: "JOIN_TOKEN", Default: "", Description: "Token for joining additional nodes to the cluster. Required for non-first nodes.", Type: "non-empty-string", Dependencies: "FIRST_NODE=false", Validators: []func(value string) error{ValidateJoinTokenArg}},
{Key: "DOMAIN", Default: "", Description: "The domain name for the cluster (e.g., \"cluster.example.com\"). Required.", Type: "non-empty-string", Dependencies: "FIRST_NODE=true"},
{Key: "NODE_IP", Default: "", Description: "The IP address to advertise for this node. Optional.", Type: "ip-address", Dependencies: "FIRST_NODE=false"},
{Key: "NODE_EXTERNAL_IP", Default: "", Description: "The external IP address to advertise for this node. Optional.", Type: "ip-address", Dependencies: "FIRST_NODE=false"},
{Key: "ADVERTISE_ADDRESS", Default: "", Description: "The address to advertise for this node. Optional.", Type: "ip-address", Dependencies: "FIRST_NODE=false"},
{Key: "CF_VALUES", Default: "", Description: "Path to ClusterForge values file (e.g., \"values_cf.yaml\"). Optional.", Type: "string"},
{Key: "USE_CERT_MANAGER", Default: false, Description: "Use cert-manager with Let's Encrypt for automatic TLS certificates.", Type: "bool", Dependencies: "FIRST_NODE=true"},
{Key: "CERT_OPTION", Default: "", Description: "Certificate option when USE_CERT_MANAGER is false. Choose 'existing' or 'generate'.", Type: "enum", Options: []string{"existing", "generate"}, Dependencies: "USE_CERT_MANAGER=false,FIRST_NODE=true"},
Expand Down
31 changes: 31 additions & 0 deletions pkg/rke2.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,9 +454,24 @@ func SetupRKE2Additional() error {
if joinToken == "" {
return fmt.Errorf("JOIN_TOKEN configuration item is not set")
}

nodeIP := viper.GetString("NODE_IP")
nodeExternalIP := viper.GetString("NODE_EXTERNAL_IP")
advertiseAddress := viper.GetString("ADVERTISE_ADDRESS")

rke2ConfigPath := "/etc/rancher/rke2/config.yaml"

configContent := fmt.Sprintf("\nserver: https://%s:9345\ntoken: %s\n", serverIP, joinToken)
if nodeIP != "" {
configContent += fmt.Sprintf("node-ip: %s\n", nodeIP)
}
if nodeExternalIP != "" {
configContent += fmt.Sprintf("node-external-ip: %s\n", nodeExternalIP)
}
if advertiseAddress != "" {
configContent += fmt.Sprintf("advertise-address: %s\n", advertiseAddress)
}

file, err := os.OpenFile(rke2ConfigPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
LogMessage(Error, fmt.Sprintf("Failed to open %s for appending: %v", rke2ConfigPath, err))
Expand Down Expand Up @@ -503,9 +518,25 @@ func SetupRKE2ControlPlane() error {
if joinToken == "" {
return fmt.Errorf("JOIN_TOKEN configuration item is not set")
}

nodeIP := viper.GetString("NODE_IP")
nodeExternalIP := viper.GetString("NODE_EXTERNAL_IP")
advertiseAddress := viper.GetString("ADVERTISE_ADDRESS")

rke2ConfigPath := "/etc/rancher/rke2/config.yaml"

configContent := fmt.Sprintf("\nserver: https://%s:9345\ntoken: %s\n", serverIP, joinToken)

if nodeIP != "" {
configContent += fmt.Sprintf("node-ip: %s\n", nodeIP)
}
if nodeExternalIP != "" {
configContent += fmt.Sprintf("node-external-ip: %s\n", nodeExternalIP)
}
if advertiseAddress != "" {
configContent += fmt.Sprintf("advertise-address: %s\n", advertiseAddress)
}

file, err := os.OpenFile(rke2ConfigPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
LogMessage(Error, fmt.Sprintf("Failed to open %s for appending: %v", rke2ConfigPath, err))
Expand Down
9 changes: 6 additions & 3 deletions pkg/rke2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,21 @@ func TestSetupRKE2Additional(t *testing.T) {
name string
serverIP string
joinToken string
internalIP string
expectError bool
}{
{"missing server IP", "", "token", true},
{"missing join token", "192.168.1.1", "", true},
{"valid inputs", "192.168.1.1", "valid-token", true}, // Will fail due to file permissions
{"missing server IP", "", "token", "", true},
{"missing join token", "192.168.1.1", "", "", true},
{"valid inputs", "192.168.1.1", "valid-token", "", true}, // Will fail due to file permissions,
{"wrong format ip", "192.168.1.1", "valid-token", "152.152.155.1525", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
viper.Set("SERVER_IP", tt.serverIP)
viper.Set("JOIN_TOKEN", tt.joinToken)
viper.Set("RKE2_INSTALLATION_URL", "https://get.rke2.io")
viper.Set("INTERNAL_IP", tt.internalIP)

err := SetupRKE2Additional()
if tt.expectError && err == nil {
Expand Down
Loading