@click.command()
@click.argument("stack_name")
+@click.option("--timeout", type=int, default=60, help="Timeout in seconds for cost retrieval operations (default: 60)")
@click.pass_context
-def cost(ctx, stack_name):
+def cost(ctx, stack_name, timeout):
"""Get Total Stack Cost."""
- os_stack.cost(ctx.obj["os_cloud"], stack_name)
+ os_stack.cost(ctx.obj["os_cloud"], stack_name, timeout=timeout)
@click.command(name="delete-stale")
print("------------------------------------")
-def cost(os_cloud, stack_name):
+def cost(os_cloud, stack_name, timeout=60):
"""Get current cost info for the stack.
Return the cost in dollars & cents (x.xx).
+
+ Args:
+ os_cloud: OpenStack cloud name from clouds.yaml
+ stack_name: Name of the stack to calculate cost for
+ timeout: Timeout in seconds for network operations (default: 60)
"""
+ import socket
def get_server_cost(server_id):
- flavor, seconds = get_server_info(server_id)
- url = "https://pricing.vexxhost.net/v1/pricing/%s/cost?seconds=%d"
- with urllib.request.urlopen(url % (flavor, seconds)) as response: # nosec
- data = json.loads(response.read())
- return data["cost"]
+ try:
+ flavor, seconds = get_server_info(server_id)
+ url = "https://pricing.vexxhost.net/v1/pricing/%s/cost?seconds=%d"
+ with urllib.request.urlopen(url % (flavor, seconds), timeout=timeout) as response: # nosec
+ data = json.loads(response.read())
+ return data["cost"]
+ except (urllib.error.URLError, socket.timeout) as e:
+ log.warning("Failed to get cost for server %s: %s", server_id, e)
+ log.warning("Returning 0 cost for this server")
+ return 0.0
+ except Exception as e:
+ log.error("Unexpected error getting cost for server %s: %s", server_id, e)
+ return 0.0
def parse_iso8601_time(time):
return datetime.strptime(time, "%Y-%m-%dT%H:%M:%S.%f")
cloud = openstack.connect(os_cloud)
- total_cost = 0.0
- for server in get_server_ids(stack_name):
- total_cost += get_server_cost(server)
- print("total: " + str(total_cost))
+ try:
+ total_cost = 0.0
+ server_ids = get_server_ids(stack_name)
+
+ if not server_ids:
+ log.info("No servers found in stack %s", stack_name)
+ print("total: 0.0")
+ return
+
+ for server in server_ids:
+ total_cost += get_server_cost(server)
+ print("total: " + str(total_cost))
+ except Exception as e:
+ log.error("Error calculating stack cost: %s", e)
+ log.warning("Returning 0 total cost due to error")
+ print("total: 0.0")
def delete(os_cloud, name_or_id, force, timeout=900):
--- /dev/null
+---
+fixes:
+ - |
+ Add timeout handling to ``lftools openstack stack cost`` command to
+ prevent indefinite hangs when retrieving stack costs from OpenStack
+ and pricing APIs.
+
+ The cost() function now accepts a ``--timeout`` option (default: 60
+ seconds) that applies to all network operations. When a timeout occurs,
+ the command gracefully degrades by returning 0 cost instead of hanging
+ indefinitely.
+
+ This fixes the issue where Jenkins jobs would hang at "INFO: Retrieving
+ stack cost for: <stack-name>" when the VEXXHOST pricing API is slow or
+ unresponsive, or when OpenStack API queries take too long with nested
+ stacks.
+
+ Key changes:
+
+ - Added timeout parameter to urllib.request.urlopen() calls
+ - Wrapped network operations in try/except blocks to catch
+ urllib.error.URLError and socket.timeout exceptions
+ - Returns 0.0 cost for individual servers that timeout
+ - Returns "total: 0.0" if the entire operation fails
+ - Added --timeout CLI option (default: 60 seconds)
+ - Enhanced logging with warning/error messages for debugging
+
+ Usage examples:
+
+ .. code-block:: bash
+
+ # Use default 60-second timeout
+ lftools openstack --os-cloud vexx stack cost my-stack-name
+
+ # Use custom 30-second timeout
+ lftools openstack --os-cloud vexx stack cost --timeout 30 my-stack-name
+
+ # Use longer timeout for complex nested stacks
+ lftools openstack --os-cloud vexx stack cost --timeout 120 my-stack-name