Skip to content

Commit be764c7

Browse files
elfiesmelfieayefimov-1
authored andcommitted
[ci] Add chargeback role to FVT jobs. Assisted by
Gemini
1 parent ac5c3d5 commit be764c7

File tree

9 files changed

+362
-1
lines changed

9 files changed

+362
-1
lines changed

ci/chargeback_tests.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
- name: "Verify all the applicable projects, endpoints, pods & services for cloudkitty"
3+
hosts: controller
4+
gather_facts: no
5+
ignore_errors: true
6+
environment:
7+
KUBECONFIG: "{{ cifmw_openshift_kubeconfig }}"
8+
PATH: "{{ cifmw_path }}"
9+
vars_files:
10+
- vars/osp18_env.yml
11+
vars:
12+
common_pod_status_str: "Running"
13+
common_pod_nspace: openstack
14+
common_pod_list:
15+
- cloudkitty-api
16+
- cloudkitty-lokistack-compactor
17+
- cloudkitty-lokistack-distributor
18+
- cloudkitty-lokistack-gateway
19+
- cloudkitty-lokistack-index-gateway
20+
- cloudkitty-lokistack-ingester
21+
- cloudkitty-lokistack-querier
22+
- cloudkitty-lokistack-query-frontend
23+
24+
common_project_list:
25+
- openstack
26+
- openstack-operators
27+
28+
common_endpoint_list:
29+
- [cloudkitty,rating,public]
30+
- [cloudkitty,rating,internal]
31+
32+
common_service_nspace: openstack
33+
common_service_list:
34+
- cloudkitty-internal
35+
- cloudkitty-lokistack-compactor-grpc
36+
- cloudkitty-lokistack-compactor-http
37+
- cloudkitty-lokistack-distributor-grpc
38+
- cloudkitty-lokistack-distributor-http
39+
- cloudkitty-lokistack-gateway-http
40+
- cloudkitty-lokistack-gossip-ring
41+
- cloudkitty-lokistack-index-gateway-grpc
42+
- cloudkitty-lokistack-index-gateway-http
43+
- cloudkitty-lokistack-ingester-grpc
44+
- cloudkitty-lokistack-ingester-http
45+
- cloudkitty-lokistack-querier-grpc
46+
- cloudkitty-lokistack-querier-http
47+
- cloudkitty-lokistack-query-frontend-grpc
48+
- cloudkitty-lokistack-query-frontend-http
49+
- cloudkitty-public
50+
51+
tasks:
52+
- name: "Verify cloudkitty infrastructure components"
53+
ansible.builtin.import_role:
54+
name: common
55+
56+
- name: "Verify cloudkitty deployment"
57+
ansible.builtin.import_role:
58+
name: observe_chargeback

ci/vars-use-master-containers.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
---
2-
post_ctlplane_deploy_99_modify_openstackversions:
2+
pre_tests_99_modify_openstackversions:
33
source: "{{ ansible_user_dir }}/{{ zuul.projects['github.com/infrawatch/feature-verification-tests'].src_dir }}/ci/use-master-containers.yml"
44
type: playbook

roles/observe_chargeback/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
Role Name
2+
=========
3+
The **`observe_chargeback`** role is designed to test the **RHOSO Cloudkitty** feature. These tests are specific to the Cloudkitty feature. Tests that are not specific to this feature (e.g., standard OpenStack deployment validation, basic networking) should be added to a common role.
4+
5+
Requirements
6+
------------
7+
The following resources are required in the OpenStack cloud
8+
* network called public
9+
* network called private
10+
* security group called basic with ssh and tcp enabled to the VMs
11+
12+
13+
It relies on the following being available on the target or control host:
14+
15+
* This role requires **Ansible 2.9** or newer.
16+
* The **OpenStack CLI client** must be installed and configured with administrative credentials (e.g., source the `openrc` file).
17+
* Required Python libraries for the `openstack` CLI (e.g., `python3-openstackclient`).
18+
* Connectivity to the OpenStack API endpoint.
19+
20+
Role Variables
21+
--------------
22+
The role uses a few primary variables to control the testing environment and execution.
23+
24+
| Variable | Default Value | Description |
25+
| `openstack_cmd` | `openstack` | The command used to execute OpenStack CLI calls. This can be customized if the binary is not in the standard PATH. |
26+
27+
Dependencies
28+
------------
29+
This role has no direct hard dependencies on other Ansible roles.
30+
31+
However, it is expected to be run **after** a successful deployment and configuration of the following components:
32+
33+
* **OpenStack:** A functional OpenStack cloud (RHOSO) environment.
34+
* **logging:** Logging service must be installed, configured, and running for Openstack
35+
* **Cloudkitty:** The Cloudkitty service must be installed, configured, and running.
36+
37+
Example Playbook
38+
----------------
39+
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too.
40+
41+
Each tasks/playbook.yml should be called independently via "ansible.builtin.import_role" with appropriate vars passed:
42+
43+
- name: "name of ansible playbook"
44+
hosts: computes
45+
gather_facts: no
46+
vars:
47+
list_name:
48+
- item-1
49+
- item-2
50+
51+
tasks:
52+
- name: "Run chargeback specific tests"
53+
ansible.builtin.import_role:
54+
name: observe_chargeback
55+
56+
Author Information
57+
------------------
58+
59+
Alex Yefimov, Red Hat
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
openstack_cmd: "openstack"
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import logging
2+
import argparse
3+
from datetime import datetime, timezone, timedelta
4+
from pathlib import Path
5+
from typing import Union
6+
from jinja2 import Template
7+
8+
# --- Configure logging with a default level that can be changed ---
9+
logging.basicConfig(
10+
level=logging.INFO,
11+
format='%(asctime)s - %(levelname)s - %(message)s',
12+
datefmt='%Y-%m-%d %H:%M:%S'
13+
)
14+
logger = logging.getLogger()
15+
16+
def _format_timestamp(epoch_seconds: float) -> str:
17+
"""
18+
Converts an epoch timestamp into a human-readable UTC string.
19+
20+
Args:
21+
epoch_seconds (float): The timestamp in seconds since the epoch.
22+
23+
Returns:
24+
str: The formatted datetime string (e.g., "2023-10-26T14:30:00 UTC").
25+
"""
26+
try:
27+
dt_object = datetime.fromtimestamp(epoch_seconds, tz=timezone.utc)
28+
return dt_object.strftime("%Y-%m-%dT%H:%M:%S %Z")
29+
except (ValueError, TypeError):
30+
logger.warning(f"Invalid epoch value provided: {epoch_seconds}")
31+
return "INVALID_TIMESTAMP"
32+
33+
def generate_loki_data(
34+
template_path: Path,
35+
output_path: Path,
36+
start_time: datetime,
37+
end_time: datetime,
38+
time_step_seconds: int
39+
):
40+
"""
41+
Generates synthetic Loki log data by first preparing a data list
42+
and then rendering it with a single template.
43+
44+
Args:
45+
template_path (Path): Path to the main log template file.
46+
output_path (Path): Path for the generated output JSON file.
47+
start_time (datetime): The start time for data generation.
48+
end_time (datetime): The end time for data generation.
49+
time_step_seconds (int): The duration of each log entry in seconds.
50+
"""
51+
52+
# --- Step 1: Generate the data structure first ---
53+
logger.info(
54+
f"Generating data from {start_time.strftime('%Y-%m-%d')} to "
55+
f"{end_time.strftime('%Y-%m-%d')} with a {time_step_seconds}s step."
56+
)
57+
start_epoch = int(start_time.timestamp())
58+
end_epoch = int(end_time.timestamp())
59+
logger.debug(f"Time range in epoch seconds: {start_epoch} to {end_epoch}")
60+
61+
log_data_list = [] # This list will hold all our data points
62+
63+
# Loop through the time range and generate data points
64+
for current_epoch in range(start_epoch, end_epoch, time_step_seconds):
65+
end_of_step_epoch = current_epoch + time_step_seconds - 1
66+
67+
# Prepare replacement values
68+
nanoseconds = int(current_epoch * 1_000_000_000)
69+
start_str = _format_timestamp(current_epoch)
70+
end_str = _format_timestamp(end_of_step_epoch)
71+
72+
logger.debug(f"Processing epoch: {current_epoch} -> nanoseconds: {nanoseconds}")
73+
74+
# Create a dictionary for this time step and add it to the list
75+
log_data_list.append({
76+
"nanoseconds": nanoseconds,
77+
"start_time": start_str,
78+
"end_time": end_str
79+
})
80+
81+
logger.info(f"Generated {len(log_data_list)} data points to be rendered.")
82+
83+
# --- Step 2: Load template and render ---
84+
try:
85+
logger.info(f"Loading main template from: {template_path}")
86+
template_content = template_path.read_text()
87+
template = Template(template_content, trim_blocks=True, lstrip_blocks=True)
88+
89+
except FileNotFoundError as e:
90+
logger.error(f"Error loading template file: {e}. Aborting.")
91+
raise # Re-raise the exception to be caught in main()
92+
93+
# --- Render the template in one pass with all the data ---
94+
logger.info("Rendering final output...")
95+
# The template expects a variable named 'log_data'
96+
final_output = template.render(log_data=log_data_list)
97+
98+
# --- Step 3: Write the final string to the file ---
99+
try:
100+
with output_path.open('w') as f_out:
101+
f_out.write(final_output)
102+
logger.info(f"Successfully generated synthetic data to '{output_path}'")
103+
except IOError as e:
104+
logger.error(f"Failed to write to output file '{output_path}': {e}")
105+
except Exception as e:
106+
logger.error(f"An unexpected error occurred during file write: {e}")
107+
108+
def main():
109+
"""Main entry point for the script."""
110+
parser = argparse.ArgumentParser(
111+
description="Generate synthetic Loki log data from a single main template.",
112+
formatter_class=argparse.ArgumentDefaultsHelpFormatter
113+
)
114+
# --- Required File Path Arguments ---
115+
parser.add_argument("-o", "--output", required=True, help="Path to the output file.")
116+
# --- Only one template argument is needed now ---
117+
parser.add_argument("--template", required=True, help="Path to the main log template file (e.g., loki_main.tmpl).")
118+
119+
# --- Optional Generation Arguments ---
120+
parser.add_argument("--days", type=int, default=30, help="How many days of data to generate, ending today.")
121+
parser.add_argument("--step", type=int, default=300, help="Time step in seconds for each log entry.")
122+
123+
# --- Optional Utility Arguments ---
124+
parser.add_argument("--debug", action="store_true", help="Enable debug level logging for verbose output.")
125+
126+
args = parser.parse_args()
127+
128+
if args.debug:
129+
logger.setLevel(logging.DEBUG)
130+
logger.debug("Debug mode enabled.")
131+
132+
# Define the time range for data generation
133+
end_time_utc = datetime.now(timezone.utc)
134+
start_time_utc = end_time_utc - timedelta(days=args.days)
135+
logger.debug(f"Time range calculated: {start_time_utc} to {end_time_utc}")
136+
137+
# Run the generator
138+
try:
139+
generate_loki_data(
140+
template_path=Path(args.template),
141+
output_path=Path(args.output),
142+
start_time=start_time_utc,
143+
end_time=end_time_utc,
144+
time_step_seconds=args.step
145+
)
146+
except FileNotFoundError:
147+
logger.error("Process aborted because the template file was not found.")
148+
except Exception as e:
149+
logger.critical(f"A critical, unhandled error stopped the script: {e}")
150+
151+
152+
if __name__ == "__main__":
153+
main()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{"streams": [{ "stream": { "service": "cloudkitty" }, "values": [
2+
{%- for item in log_data %}
3+
[
4+
"{{ item.nanoseconds }}",
5+
"{\"start\": \"{{ item.start_time }}\", \"end\": \"{{ item.end_time }}\", \"type\": \"image.size\", \"unit\": \"MiB\", \"description\": null, \"qty\": 20.6875, \"price\": 0.0206875, \"groupby\": {\"id\": \"cd65d30f-8b94-4fa3-95dc-e3b429f479b2\", \"project_id\": \"0030775de80e4d84a4fd0d73e0a1b3a7\", \"user_id\": null, \"week_of_the_year\": \"37\", \"day_of_the_year\": \"258\", \"month\": \"9\", \"year\": \"2025\"}, \"metadata\": {\"container_format\": \"bare\", \"disk_format\": \"qcow2\"}}"
6+
],
7+
[
8+
"{{ item.nanoseconds }}",
9+
"{\"start\": \"{{ item.start_time }}\", \"end\": \"{{ item.end_time }}\", \"type\": \"instance\", \"unit\": \"instance\", \"description\": null, \"qty\": 1.0, \"price\": 0.3, \"groupby\": {\"id\": \"de168c31-ed44-4a1a-a079-51bd238a91d6\", \"project_id\": \"9cf5bcfc61a24682acc448af2d062ad2\", \"user_id\": \"c29ab6e886354bbd88ee9899e62d1d40\", \"week_of_the_year\": \"37\", \"day_of_the_year\": \"258\", \"month\": \"9\", \"year\": \"2025\"}, \"metadata\": {\"flavor_name\": \"m1.tiny\", \"flavor_id\": \"1\", \"vcpus\": \"\"}}"
10+
]
11+
{#- This logic adds a comma after every pair, *except* for the very last one. #}
12+
{%- if not loop.last -%}
13+
,
14+
{%- endif -%}
15+
{%- endfor %}
16+
]}]}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
galaxy_info:
3+
author: Alex Yefimov
4+
description: Tests the chargeback feature is set up in OpenStack running running in OpenShift
5+
company: Red Hat
6+
7+
license: Apache-2.0
8+
9+
min_ansible_version: "2.1"
10+
11+
galaxy_tags: []
12+
13+
dependencies: []
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
- name: Enable Cloudkitty Module (hashmap)
3+
ansible.builtin.command:
4+
cmd: "{{ openstack_cmd }} rating module enable hashmap"
5+
register: enable_hashmap
6+
# The task is only 'changed' if the output confirms the module was enabled/changed
7+
# This is the critical line to resolve the 'no-changed-when' error
8+
changed_when: "'Enabled' in enable_hashmap.stdout or 'change' in enable_hashmap.stdout"
9+
failed_when: enable_hashmap.rc != 0
10+
11+
- name: Enable Cloudkitty Module (noop)
12+
ansible.builtin.command:
13+
cmd: "{{ openstack_cmd }} rating module enable noop"
14+
register: enable_noop
15+
changed_when: "'Enabled' in enable_noop.stdout or 'change' in enable_noop.stdout"
16+
failed_when: enable_noop.rc != 0
17+
18+
- name: Disable Cloudkitty Module (pyscripts)
19+
ansible.builtin.command:
20+
cmd: "{{ openstack_cmd }} rating module disable pyscripts"
21+
register: disable_pyscripts
22+
changed_when: "'Disabled' in disable_pyscripts.stdout or 'change' in disable_pyscripts.stdout"
23+
failed_when: disable_pyscripts.rc != 0
24+
25+
- name: Find the current value of hashmap
26+
ansible.builtin.shell:
27+
cmd: "{{ openstack_cmd }} rating module get hashmap -c Priority -f csv | tail -n +2"
28+
register: get_hashmap_priority
29+
changed_when: false
30+
31+
- name: Change priority for CloudKitty hashmap module
32+
ansible.builtin.command:
33+
cmd: "{{ openstack_cmd }} rating module set priority hashmap 100"
34+
register: set_hashmap_priority
35+
when: get_hashmap_priority.stdout | trim != '100'
36+
failed_when: set_hashmap_priority.rc >= 1 or get_hashmap_priority.stdout == ""
37+
changed_when: set_hashmap_priority.rc === 0
38+
39+
- name: Get status of all CloudKitty rating modules
40+
ansible.builtin.command:
41+
cmd: "{{ openstack_cmd }} rating module list"
42+
changed_when: false
43+
register: module_list
44+
45+
- name: TEST Validate CloudKitty module states
46+
ansible.builtin.assert:
47+
that:
48+
- "'noop' in module_list.stdout and 'True' in (module_list.stdout_lines | select('search', 'noop') | first)"
49+
- "'hashmap' in module_list.stdout and 'True' in (module_list.stdout_lines | select('search', 'hashmap') | first)"
50+
- "'pyscripts' in module_list.stdout and 'False' in (module_list.stdout_lines | select('search', 'pyscripts') | first)"
51+
fail_msg: "CloudKitty module validation FAILED. Module states are not as expected."
52+
success_msg: "SUCCESS: CloudKitty modules (noop=True, hashmap=True, pyscripts=False) are configured correctly."
53+
54+
- name: TEST Set priority for CloudKitty hashmap module
55+
ansible.builtin.debug:
56+
msg: "The hashmap priority is set to 100"
57+
when: (get_hashmap_priority.stdout | trim == '100') or (set_hashmap_priority.rc is defined and set_hashmap_priority.rc == 0)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
---
2+
- name: "Validate ChargeBack Feature"
3+
ansible.builtin.include_tasks: "chargeback_tests.yml"

0 commit comments

Comments
 (0)