Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions .github/actions/setup-cloudstack/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,30 @@ runs:
run: |
echo "Starting Cloudstack health check"
T=0
until [ $T -gt 20 ] || curl -sfL http://localhost:8080 --output /dev/null
MAX_WAIT=40
SLEEP_INTERVAL=30

echo "Will wait up to $((MAX_WAIT * SLEEP_INTERVAL / 60)) minutes for CloudStack to be ready"

until [ $T -gt $MAX_WAIT ] || curl -sfL http://localhost:8080 --output /dev/null
do
echo "Waiting for Cloudstack to be ready..."
echo "Waiting for Cloudstack to be ready... (attempt $((T+1))/$((MAX_WAIT+1)), elapsed: $((T * SLEEP_INTERVAL / 60))m $((T * SLEEP_INTERVAL % 60))s)"
((T+=1))
sleep 30
sleep $SLEEP_INTERVAL
done

# After loop, check if Cloudstack is up
if ! curl -sfSL http://localhost:8080 --output /dev/null; then
echo "Cloudstack did not become ready in time"
echo "Cloudstack did not become ready in time after $((MAX_WAIT * SLEEP_INTERVAL / 60)) minutes"
echo "Checking CloudStack container status:"
docker ps -a | grep cloudstack || echo "No CloudStack containers found"
echo "Checking CloudStack logs:"
docker logs $(docker ps -q --filter "ancestor=apache/cloudstack-simulator") --tail 50 || echo "Could not retrieve logs"
echo "Testing connectivity:"
curl -v http://localhost:8080 || true
exit 22
else
echo "CloudStack is ready after $((T * SLEEP_INTERVAL / 60))m $((T * SLEEP_INTERVAL % 60))s"
fi
- name: Setting up Cloudstack
id: setup-cloudstack
Expand All @@ -64,8 +76,23 @@ runs:
set -euo pipefail

echo "Deploying Data Center..."
docker exec $(docker container ls --format=json -l | jq -r .ID) \
python /root/tools/marvin/marvin/deployDataCenter.py -i /root/setup/dev/advanced.cfg
# Retry data center deployment up to 3 times
for attempt in 1 2 3; do
echo "Data center deployment attempt $attempt/3"
if docker exec $(docker container ls --format=json -l | jq -r .ID) \
python /root/tools/marvin/marvin/deployDataCenter.py -i /root/setup/dev/advanced.cfg; then
echo "Data center deployment successful on attempt $attempt"
break
else
echo "Data center deployment failed on attempt $attempt"
if [ $attempt -eq 3 ]; then
echo "All data center deployment attempts failed"
exit 1
fi
echo "Waiting 30 seconds before retry..."
sleep 30
fi
done

# Get the container ID of the running simulator
CONTAINER_ID=$(docker ps --filter "ancestor=apache/cloudstack-simulator:${{ matrix.cloudstack-version }}" --format "{{.ID}}" | head -n1)
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/acceptance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ jobs:
name: Terraform ${{ matrix.terraform-version }} with Cloudstack ${{ matrix.cloudstack-version }}
needs: [prepare-matrix]
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Set up Go
Expand Down Expand Up @@ -86,6 +87,7 @@ jobs:
name: OpenTofu ${{ matrix.opentofu-version }} with Cloudstack ${{ matrix.cloudstack-version }}
needs: [prepare-matrix]
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Set up Go
Expand Down
2 changes: 1 addition & 1 deletion GNUmakefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ test: fmtcheck
xargs -t -n4 go test $(TESTARGS) -timeout=30s -parallel=4

testacc: fmtcheck
TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 30m
TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 60m

vet:
@echo "go vet ."
Expand Down
207 changes: 194 additions & 13 deletions cloudstack/resource_cloudstack_egress_firewall.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package cloudstack

import (
"fmt"
"sort"
"strconv"
"strings"
"sync"
Expand All @@ -31,6 +32,81 @@ import (
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

// treats 'all ports' for tcp/udp across CS versions returning 0/0, -1/-1, or 1/65535
func isAllPortsTCPUDP(protocol string, start, end int) bool {
p := strings.ToLower(protocol)
if p != "tcp" && p != "udp" {
return false
}
// handle various representations of all ports across CloudStack versions
if (start == 0 && end == 0) ||
(start == -1 && end == -1) ||
(start == 1 && end == 65535) {
return true
}
return false
}

// normalizeRemoteCIDRs normalizes a comma-separated CIDR string from CloudStack API
func normalizeRemoteCIDRs(cidrList string) []string {
if cidrList == "" {
return []string{}
}

cidrs := strings.Split(cidrList, ",")
normalized := make([]string, 0, len(cidrs))

for _, cidr := range cidrs {
trimmed := strings.TrimSpace(cidr)
if trimmed != "" {
normalized = append(normalized, trimmed)
}
}

sort.Strings(normalized)
return normalized
}

// normalizeLocalCIDRs normalizes a Terraform schema.Set of CIDRs
func normalizeLocalCIDRs(cidrSet *schema.Set) []string {
if cidrSet == nil {
return []string{}
}

normalized := make([]string, 0, cidrSet.Len())
for _, cidr := range cidrSet.List() {
if cidrStr, ok := cidr.(string); ok {
trimmed := strings.TrimSpace(cidrStr)
if trimmed != "" {
normalized = append(normalized, trimmed)
}
}
}

sort.Strings(normalized)
return normalized
}

// cidrSetsEqual compares normalized CIDR sets for equality (order/whitespace agnostic)
func cidrSetsEqual(remoteCidrList string, localCidrSet *schema.Set) bool {
remoteCidrs := normalizeRemoteCIDRs(remoteCidrList)
localCidrs := normalizeLocalCIDRs(localCidrSet)

// Compare lengths first
if len(remoteCidrs) != len(localCidrs) {
return false
}

// Compare each element (both are already sorted)
for i, remoteCidr := range remoteCidrs {
if remoteCidr != localCidrs[i] {
return false
}
}

return true
}

func resourceCloudStackEgressFirewall() *schema.Resource {
return &schema.Resource{
Create: resourceCloudStackEgressFirewallCreate,
Expand Down Expand Up @@ -250,6 +326,17 @@ func createEgressFirewallRule(d *schema.ResourceData, meta interface{}, rule map
uuids[port.(string)] = r.Id
rule["uuids"] = uuids
}
} else {
// No ports specified - create a rule that encompasses all ports
// by not setting startport and endport parameters
r, err := cs.Firewall.CreateEgressFirewallRule(p)
if err != nil {
return fmt.Errorf("failed to create all-ports egress firewall rule: %w", err)
}
uuids["all"] = r.Id
rule["uuids"] = uuids
// Remove the ports field since we're creating an all-ports rule
delete(rule, "ports")
}
}

Expand Down Expand Up @@ -315,8 +402,13 @@ func resourceCloudStackEgressFirewallRead(d *schema.ResourceData, meta interface

// Create a set with all CIDR's
cidrs := &schema.Set{F: schema.HashString}
for _, cidr := range strings.Split(r.Cidrlist, ",") {
cidrs.Add(cidr)
if r.Cidrlist != "" {
for _, cidr := range strings.Split(r.Cidrlist, ",") {
cidr = strings.TrimSpace(cidr)
if cidr != "" {
cidrs.Add(cidr)
}
}
}

// Update the values
Expand Down Expand Up @@ -353,8 +445,13 @@ func resourceCloudStackEgressFirewallRead(d *schema.ResourceData, meta interface

// Create a set with all CIDR's
cidrs := &schema.Set{F: schema.HashString}
for _, cidr := range strings.Split(r.Cidrlist, ",") {
cidrs.Add(cidr)
if r.Cidrlist != "" {
for _, cidr := range strings.Split(r.Cidrlist, ",") {
cidr = strings.TrimSpace(cidr)
if cidr != "" {
cidrs.Add(cidr)
}
}
}

// Update the values
Expand All @@ -368,8 +465,88 @@ func resourceCloudStackEgressFirewallRead(d *schema.ResourceData, meta interface
rule["ports"] = ports
rules.Add(rule)
}
} else {
// No ports specified - check if we have an "all" rule
id, ok := uuids["all"]
if !ok {
continue
}

// Get the rule
r, ok := ruleMap[id.(string)]
if !ok {
delete(uuids, "all")
continue
}

// Verify this is actually an all-ports rule using our helper
if !isAllPortsTCPUDP(r.Protocol, r.Startport, r.Endport) {
// This rule has specific ports, but we expected all-ports
// This might happen if CloudStack behavior changed
continue
}

// Delete the known rule so only unknown rules remain in the ruleMap
delete(ruleMap, id.(string))

// Create a set with all CIDR's
cidrs := &schema.Set{F: schema.HashString}
if r.Cidrlist != "" {
for _, cidr := range strings.Split(r.Cidrlist, ",") {
cidr = strings.TrimSpace(cidr)
if cidr != "" {
cidrs.Add(cidr)
}
}
}

// Update the values
rule["protocol"] = r.Protocol
rule["cidr_list"] = cidrs
// Remove ports field for all-ports rules
delete(rule, "ports")
rules.Add(rule)
}
}

// Fallback: Check if any remaining rules in ruleMap match our expected all-ports pattern
// This handles cases where CloudStack might return all-ports rules in unexpected formats
if rule["protocol"].(string) != "icmp" && strings.ToLower(rule["protocol"].(string)) != "all" {
// Look for any remaining rules that might be our all-ports rule
for ruleID, r := range ruleMap {
// Get local CIDR set for comparison
localCidrSet, ok := rule["cidr_list"].(*schema.Set)
if !ok {
continue
}

if isAllPortsTCPUDP(r.Protocol, r.Startport, r.Endport) &&
strings.EqualFold(r.Protocol, rule["protocol"].(string)) &&
cidrSetsEqual(r.Cidrlist, localCidrSet) {
// This looks like our all-ports rule, add it to state
cidrs := &schema.Set{F: schema.HashString}
if r.Cidrlist != "" {
for _, cidr := range strings.Split(r.Cidrlist, ",") {
cidr = strings.TrimSpace(cidr)
if cidr != "" {
cidrs.Add(cidr)
}
}
}

rule["protocol"] = r.Protocol
rule["cidr_list"] = cidrs
// Remove ports field for all-ports rules
delete(rule, "ports")
rules.Add(rule)

// Remove from ruleMap so it's not processed again
delete(ruleMap, ruleID)
break
}
}
}

if strings.ToLower(rule["protocol"].(string)) == "all" {
id, ok := uuids["all"]
if !ok {
Expand All @@ -389,8 +566,13 @@ func resourceCloudStackEgressFirewallRead(d *schema.ResourceData, meta interface
// Create a set with all CIDR's
if _, ok := rule["cidr_list"]; ok {
cidrs := &schema.Set{F: schema.HashString}
for _, cidr := range strings.Split(r.Cidrlist, ",") {
cidrs.Add(cidr)
if r.Cidrlist != "" {
for _, cidr := range strings.Split(r.Cidrlist, ",") {
cidr = strings.TrimSpace(cidr)
if cidr != "" {
cidrs.Add(cidr)
}
}
}
rule["cidr_list"] = cidrs
}
Expand Down Expand Up @@ -575,7 +757,7 @@ func verifyEgressFirewallParams(d *schema.ResourceData) error {

if !rules && !managed {
return fmt.Errorf(
"You must supply at least one 'rule' when not using the 'managed' firewall feature")
"you must supply at least one 'rule' when not using the 'managed' firewall feature")
}

return nil
Expand All @@ -591,11 +773,11 @@ func verifyEgressFirewallRuleParams(d *schema.ResourceData, rule map[string]inte
if protocol == "icmp" {
if _, ok := rule["icmp_type"]; !ok {
return fmt.Errorf(
"Parameter icmp_type is a required parameter when using protocol 'icmp'")
"parameter icmp_type is a required parameter when using protocol 'icmp'")
}
if _, ok := rule["icmp_code"]; !ok {
return fmt.Errorf(
"Parameter icmp_code is a required parameter when using protocol 'icmp'")
"parameter icmp_code is a required parameter when using protocol 'icmp'")
}
} else if strings.ToLower(protocol) != "all" {
if ports, ok := rule["ports"].(*schema.Set); ok {
Expand All @@ -606,14 +788,13 @@ func verifyEgressFirewallRuleParams(d *schema.ResourceData, rule map[string]inte
"%q is not a valid port value. Valid options are '80' or '80-90'", port.(string))
}
}
} else {
return fmt.Errorf(
"Parameter ports is a required parameter when *not* using protocol 'icmp'")
}
// Note: ports parameter is optional for TCP/UDP protocols
// When omitted, the rule will encompass all ports
} else if strings.ToLower(protocol) == "all" {
if ports, _ := rule["ports"].(*schema.Set); ports.Len() > 0 {
return fmt.Errorf(
"Parameter ports is not required when using protocol 'ALL'")
"parameter ports is not required when using protocol 'ALL'")
}
}

Expand Down
Loading
Loading