diff --git a/src/telemetry/galaxy.yml b/src/telemetry/galaxy.yml index 3e9c35028f..ac601febea 100644 --- a/src/telemetry/galaxy.yml +++ b/src/telemetry/galaxy.yml @@ -21,7 +21,7 @@ authors: description: >- Dell Omnia Telemetry — deploys and manages a telemetry stack for HPC and AI clusters. Supports VictoriaMetrics, VictoriaLogs, Kafka (Strimzi), LDMS, - iDRAC, UFM, OME, PowerScale, VAST, and SFM + iDRAC, UFM, OME, PowerScale, and VAST telemetry sources with tag-based deployment and cleanup. license: - Apache-2.0 diff --git a/src/telemetry/playbooks/cleanup/cleanup.yml b/src/telemetry/playbooks/cleanup/cleanup.yml index 32a7b7c468..bccbe8a948 100644 --- a/src/telemetry/playbooks/cleanup/cleanup.yml +++ b/src/telemetry/playbooks/cleanup/cleanup.yml @@ -44,7 +44,7 @@ # Available tags: # Sinks: cleanup_kafka, cleanup_victoria_metrics, cleanup_victoria_logs # Sources: cleanup_idrac, cleanup_ldms, cleanup_ome, cleanup_powerscale, -# cleanup_ufm, cleanup_vast, cleanup_sfm +# cleanup_ufm, cleanup_vast # Design: Sinks (Kafka, VictoriaMetrics, VictoriaLogs) are shared # infrastructure that multiple sources depend on. They are ONLY cleaned # when --tags cleanup (all) is used, never individually. @@ -63,7 +63,7 @@ connection: local gather_facts: false any_errors_fatal: true - tags: [cleanup, cleanup_idrac, cleanup_ldms, cleanup_ome, cleanup_powerscale, cleanup_ufm, cleanup_vast, cleanup_sfm, cleanup_kafka, cleanup_victoria_metrics, cleanup_victoria_logs] # noqa: yaml[line-length] + tags: [cleanup, cleanup_idrac, cleanup_ldms, cleanup_ome, cleanup_powerscale, cleanup_ufm, cleanup_vast, cleanup_kafka, cleanup_victoria_metrics, cleanup_victoria_logs] # noqa: yaml[line-length] tasks: - name: Load telemetry configuration ansible.builtin.include_role: @@ -93,7 +93,6 @@ _cleanup_powerscale: "{{ 'cleanup_powerscale' in ansible_run_tags }}" _cleanup_ufm: "{{ 'cleanup_ufm' in ansible_run_tags }}" _cleanup_vast: "{{ 'cleanup_vast' in ansible_run_tags }}" - _cleanup_sfm: "{{ 'cleanup_sfm' in ansible_run_tags }}" cacheable: true # --------------------------------------------------------------------------- @@ -140,12 +139,6 @@ tasks_from: vast when: hostvars['localhost']['_cleanup_all'] | bool or hostvars['localhost']['_cleanup_vast'] | bool - - name: "Cleanup SFM telemetry" - ansible.builtin.include_role: - name: cleanup - tasks_from: sfm - when: hostvars['localhost']['_cleanup_all'] | bool or hostvars['localhost']['_cleanup_sfm'] | bool - # -- Pre-sink guard: verify source pods have terminated -- - name: "Check for remaining source pods before sink cleanup" vars: @@ -153,7 +146,7 @@ _source_apps: >- idrac-telemetry,nersc-ldms,vector-ome,vector-ldms, karavi-metrics-powerscale,otel-collector, - csi-volume-exporter,sfm-telemetry, + csi-volume-exporter, ufm-external,vast-external,skyway-telemetry,powervault-telemetry ansible.builtin.command: > kubectl get pods -n {{ telemetry_namespace }} @@ -179,7 +172,7 @@ _source_apps: >- idrac-telemetry,nersc-ldms,vector-ome,vector-ldms, karavi-metrics-powerscale,otel-collector, - csi-volume-exporter,sfm-telemetry, + csi-volume-exporter, ufm-external,vast-external,skyway-telemetry,powervault-telemetry ansible.builtin.command: > kubectl wait --for=delete pods @@ -216,7 +209,7 @@ - name: "Telemetry Cleanup | Verification" hosts: kube_vip_group gather_facts: false - tags: [cleanup, cleanup_idrac, cleanup_ldms, cleanup_ome, cleanup_powerscale, cleanup_ufm, cleanup_vast, cleanup_sfm, cleanup_kafka, cleanup_victoria_metrics, cleanup_victoria_logs] # noqa: yaml[line-length] + tags: [cleanup, cleanup_idrac, cleanup_ldms, cleanup_ome, cleanup_powerscale, cleanup_ufm, cleanup_vast, cleanup_kafka, cleanup_victoria_metrics, cleanup_victoria_logs] # noqa: yaml[line-length] vars: telemetry_namespace: "telemetry" tasks: @@ -245,7 +238,7 @@ hosts: localhost connection: local gather_facts: false - tags: [cleanup, cleanup_idrac, cleanup_ldms, cleanup_ome, cleanup_powerscale, cleanup_ufm, cleanup_vast, cleanup_sfm, cleanup_kafka, cleanup_victoria_metrics, cleanup_victoria_logs] # noqa: yaml[line-length] + tags: [cleanup, cleanup_idrac, cleanup_ldms, cleanup_ome, cleanup_powerscale, cleanup_ufm, cleanup_vast, cleanup_kafka, cleanup_victoria_metrics, cleanup_victoria_logs] # noqa: yaml[line-length] tasks: - name: Display cleanup summary vars: diff --git a/src/telemetry/playbooks/cleanup/sources/cleanup_sfm.yml b/src/telemetry/playbooks/cleanup/sources/cleanup_sfm.yml deleted file mode 100644 index 8a2155a4a7..0000000000 --- a/src/telemetry/playbooks/cleanup/sources/cleanup_sfm.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. ---- - -# ============================================================================= -# SFM Telemetry Cleanup -# ============================================================================= -# Removes SFM exporter deployment, service, and scrape configmap. -# -# Standalone usage: -# ansible-playbook cleanup/sources/cleanup_sfm.yml -# -# Via main cleanup (tag-based): -# ansible-playbook cleanup/cleanup.yml --tags cleanup_sfm -# ============================================================================= - -- name: "Cleanup SFM Telemetry" - hosts: kube_vip_group - gather_facts: false - tasks: - - name: "Run sfm cleanup" - ansible.builtin.include_role: - name: cleanup - tasks_from: sfm diff --git a/src/telemetry/playbooks/deploy/deploy.yml b/src/telemetry/playbooks/deploy/deploy.yml index b1451b6a53..545935f933 100644 --- a/src/telemetry/playbooks/deploy/deploy.yml +++ b/src/telemetry/playbooks/deploy/deploy.yml @@ -69,11 +69,6 @@ hostvars['localhost']['telemetry_config'].telemetry_bridges.vector_ome.metrics_enabled | default(false) | bool or hostvars['localhost']['telemetry_config'].telemetry_bridges.vector_ome.logs_enabled | default(false) | bool -- name: "Phase 2 | Deploy SFM telemetry" - ansible.builtin.import_playbook: sources/deploy_sfm.yml - tags: deploy - when: hostvars['localhost']['telemetry_config'].telemetry_sources.sfm.metrics_enabled | default(false) | bool - # ── Phase 3: Reconciliation — root kustomization + full-stack apply ─────────── - name: "Reconcile full-stack deployment via kustomization" hosts: kube_vip_group @@ -148,6 +143,239 @@ Check pod logs with: kubectl logs -n {{ telemetry_namespace }} when: failed_pods | length > 0 +# ── Phase 4: Collect deployment results ─────────────────────────────────────── +# Inspect cluster state to determine passed/failed/skipped for each component +- name: "Collect deployment results" + hosts: kube_vip_group + gather_facts: false + vars_files: + - ../../roles/common/vars/main.yml + tags: deploy + tasks: + # Load config for checking enabled flags + - name: Load telemetry config from localhost + ansible.builtin.set_fact: + _cfg: "{{ hostvars['localhost']['telemetry_config'] }}" + + # -- Sink checks ---------------------------------------------------------- + - name: Check Kafka pods + ansible.builtin.command: > + kubectl get pods -n {{ telemetry_namespace }} -l strimzi.io/cluster=kafka --no-headers + register: _kafka_pods + changed_when: false + failed_when: false + + - name: Check VictoriaMetrics pods + ansible.builtin.command: > + kubectl get pods -n {{ telemetry_namespace }} -l app.kubernetes.io/name=vmstorage --no-headers + register: _vm_pods + changed_when: false + failed_when: false + + - name: Check VictoriaLogs pods + ansible.builtin.command: > + kubectl get pods -n {{ telemetry_namespace }} -l app.kubernetes.io/name=vlstorage --no-headers + register: _vl_pods + changed_when: false + failed_when: false + + # -- Source checks -------------------------------------------------------- + - name: Check iDRAC pods + ansible.builtin.command: > + kubectl get pods -n {{ telemetry_namespace }} -l app=idrac-telemetry --no-headers + register: _idrac_pods + changed_when: false + failed_when: false + + - name: Check LDMS pods (nersc-ldms) + ansible.builtin.command: > + kubectl get pods -n {{ telemetry_namespace }} -l app=nersc-ldms --no-headers + register: _ldms_pods + changed_when: false + failed_when: false + + - name: Check PowerScale pods (karavi-metrics-powerscale) + ansible.builtin.command: > + kubectl get pods -n {{ telemetry_namespace }} -l app.kubernetes.io/name=karavi-metrics-powerscale --no-headers + register: _powerscale_pods + changed_when: false + failed_when: false + + - name: Check UFM service (ufm-external) + ansible.builtin.command: > + kubectl get svc -n {{ telemetry_namespace }} ufm-external --no-headers + register: _ufm_check + changed_when: false + failed_when: false + + - name: Check VAST service (vast-external) + ansible.builtin.command: > + kubectl get svc -n {{ telemetry_namespace }} vast-external --no-headers + register: _vast_check + changed_when: false + failed_when: false + + - name: Check Vector-OME pods + ansible.builtin.command: > + kubectl get pods -n {{ telemetry_namespace }} -l app=vector-ome --no-headers + register: _vector_ome_pods + changed_when: false + failed_when: false + + - name: Check Vector-LDMS pods + ansible.builtin.command: > + kubectl get pods -n {{ telemetry_namespace }} -l app=vector-ldms --no-headers + register: _vector_ldms_pods + changed_when: false + failed_when: false + + - name: Check VLAgent pods (for logs sinks) + ansible.builtin.command: > + kubectl get pods -n {{ telemetry_namespace }} -l app.kubernetes.io/name=vlagent --no-headers + register: _vlagent_pods + changed_when: false + failed_when: false + + # -- Store results in hostvars for localhost to read ---------------------- + - name: Store kubectl results as facts + ansible.builtin.set_fact: + _kv: + _kafka_pods: "{{ _kafka_pods }}" + _vm_pods: "{{ _vm_pods }}" + _vl_pods: "{{ _vl_pods }}" + _idrac_pods: "{{ _idrac_pods }}" + _ldms_pods: "{{ _ldms_pods }}" + _powerscale_pods: "{{ _powerscale_pods }}" + _ufm_check: "{{ _ufm_check }}" + _vast_check: "{{ _vast_check }}" + _vector_ome_pods: "{{ _vector_ome_pods }}" + _vector_ldms_pods: "{{ _vector_ldms_pods }}" + _vlagent_pods: "{{ _vlagent_pods }}" + cacheable: true + +# ── Phase 4b: Set deployment result facts on localhost ──────────────────────── +- name: "Set deployment result facts" + hosts: localhost + connection: local + gather_facts: false + vars_files: + - ../../roles/common/vars/main.yml + tags: deploy + tasks: + - name: Get kubectl results from kube_vip + ansible.builtin.set_fact: + _kv: "{{ hostvars[kube_vip]['_kv'] }}" + _cfg: "{{ telemetry_config }}" + + # -- Sinks ---------------------------------------------------------------- + - name: Set sink results + vars: + _kafka_running: >- + {{ (_kv['_kafka_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 }} + _vm_running: >- + {{ (_kv['_vm_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 }} + _vl_running: >- + {{ (_kv['_vl_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 }} + ansible.builtin.set_fact: + _deploy_result_kafka: "{{ 'passed' if (_kafka_running | bool) else 'failed' }}" + _deploy_result_victoria_metrics: "{{ 'passed' if (_vm_running | bool) else 'failed' }}" + _deploy_result_victoria_logs: "{{ 'passed' if (_vl_running | bool) else 'failed' }}" + + # -- Sources (metrics only) ----------------------------------------------- + - name: Set source metrics-only results + vars: + _idrac_on: "{{ _cfg.telemetry_sources.idrac.metrics_enabled | default(false) | bool }}" + _idrac_running: >- + {{ (_kv['_idrac_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 }} + _ldms_on: "{{ _cfg.telemetry_sources.ldms.metrics_enabled | default(false) | bool }}" + _ldms_running: >- + {{ (_kv['_ldms_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 + or (_kv['_vector_ldms_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 }} + ansible.builtin.set_fact: + _deploy_result_idrac_metrics: >- + {{ 'skipped' if not (_idrac_on | bool) + else ('passed' if (_idrac_running | bool) else 'failed') }} + _deploy_result_ldms_metrics: >- + {{ 'skipped' if not (_ldms_on | bool) + else ('passed' if (_ldms_running | bool) else 'failed') }} + + # -- Sources (metrics + logs) --------------------------------------------- + - name: Set source metrics+logs results + vars: + _ps_m_on: "{{ _cfg.telemetry_sources.powerscale.metrics_enabled | default(false) | bool }}" + _ps_l_on: "{{ _cfg.telemetry_sources.powerscale.logs_enabled | default(false) | bool }}" + _ps_running: >- + {{ (_kv['_powerscale_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 }} + _vlagent_running: >- + {{ (_kv['_vlagent_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 }} + _ufm_m_on: "{{ _cfg.telemetry_sources.ufm.metrics_enabled | default(false) | bool }}" + _ufm_l_on: "{{ _cfg.telemetry_sources.ufm.logs_enabled | default(false) | bool }}" + _ufm_ok: "{{ _kv['_ufm_check'].rc | default(1) == 0 }}" + _vast_m_on: "{{ _cfg.telemetry_sources.vast.metrics_enabled | default(false) | bool }}" + _vast_l_on: "{{ _cfg.telemetry_sources.vast.logs_enabled | default(false) | bool }}" + _ome_m_on: "{{ _cfg.telemetry_sources.ome.metrics_enabled | default(false) | bool }}" + _ome_l_on: "{{ _cfg.telemetry_sources.ome.logs_enabled | default(false) | bool }}" + _vome_running: >- + {{ (_kv['_vector_ome_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 }} + ansible.builtin.set_fact: + _deploy_result_powerscale_metrics: >- + {{ 'skipped' if not (_ps_m_on | bool) + else ('passed' if (_ps_running | bool) else 'failed') }} + _deploy_result_powerscale_logs: >- + {{ 'skipped' if not (_ps_l_on | bool) + else ('passed' if (_vlagent_running | bool) else 'failed') }} + _deploy_result_ufm_metrics: >- + {{ 'skipped' if not (_ufm_m_on | bool) + else ('passed' if (_ufm_ok | bool) else 'failed') }} + _deploy_result_ufm_logs: >- + {{ 'skipped' if not (_ufm_l_on | bool) + else ('passed' if (_vlagent_running | bool) else 'failed') }} + _deploy_result_vast_metrics: >- + {{ 'skipped' if not (_vast_m_on | bool) else 'passed' }} + _deploy_result_vast_logs: >- + {{ 'skipped' if not (_vast_l_on | bool) else 'passed' }} + _deploy_result_ome_metrics: >- + {{ 'skipped' if not (_ome_m_on | bool) + else ('passed' if (_vome_running | bool) else 'failed') }} + _deploy_result_ome_logs: >- + {{ 'skipped' if not (_ome_l_on | bool) + else ('passed' if (_vome_running | bool) else 'failed') }} + + # -- Bridges -------------------------------------------------------------- + # Bridge status depends on both bridge config AND source config + - name: Set bridge results + vars: + _vldms_bridge_on: >- + {{ _cfg.telemetry_bridges.vector_ldms.metrics_enabled | default(false) | bool }} + _ldms_src_on: >- + {{ _cfg.telemetry_sources.ldms.metrics_enabled | default(false) | bool }} + _vldms_running: >- + {{ (_kv['_vector_ldms_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 }} + _vome_m: >- + {{ _cfg.telemetry_bridges.vector_ome.metrics_enabled | default(false) | bool }} + _vome_l: >- + {{ _cfg.telemetry_bridges.vector_ome.logs_enabled | default(false) | bool }} + _vome_running: >- + {{ (_kv['_vector_ome_pods'].stdout_lines | default([]) + | select('search', 'Running') | list | length) > 0 }} + ansible.builtin.set_fact: + _deploy_result_vector_ldms: >- + {{ 'skipped' if not ((_vldms_bridge_on | bool) and (_ldms_src_on | bool)) + else ('passed' if (_vldms_running | bool) else 'failed') }} + _deploy_result_vector_ome: >- + {{ 'skipped' if not ((_vome_m | bool) or (_vome_l | bool)) + else ('passed' if (_vome_running | bool) else 'failed') }} + # ── Phase 5: Write telemetry_status.yml ─────────────────────────────────────── - name: "Write telemetry deployment status" hosts: localhost diff --git a/src/telemetry/playbooks/deploy/sources/deploy_sfm.yml b/src/telemetry/playbooks/deploy/sources/deploy_sfm.yml deleted file mode 100644 index cbc74c8663..0000000000 --- a/src/telemetry/playbooks/deploy/sources/deploy_sfm.yml +++ /dev/null @@ -1,77 +0,0 @@ ---- -# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Enable SFM (Storage Fabric Manager) Telemetry -# Usage: Called from telemetry.yml or standalone with kube_vip -# ansible-playbook deploy/sources/deploy_sfm.yml -e kube_vip= -# -# Deploys shared infrastructure (VictoriaMetrics) if not present, -# then generates SFM scrape configuration manifests. -# All K8s tasks execute on the kube_vip host. - -- name: Deploy SFM Telemetry - hosts: kube_vip_group - gather_facts: true - any_errors_fatal: true - vars_files: - - ../../../roles/common/vars/main.yml - pre_tasks: - - name: Load telemetry config (only if not already loaded) - ansible.builtin.include_role: - name: common - tasks_from: load_telemetry_config - when: telemetry_config is not defined - - - name: Derive sink support flags (only if not already derived) - ansible.builtin.include_role: - name: common - tasks_from: derive_sink_support_flags - when: kafka_support is not defined - - - name: Add kube_vip to inventory (only if not already added) - ansible.builtin.add_host: - name: "{{ kube_vip }}" - groups: kube_vip_group - ansible_user: root - when: kube_vip_group is not defined - tasks: - - name: Set telemetry credentials from localhost - ansible.builtin.set_fact: - telemetry_credentials: "{{ hostvars['localhost']['telemetry_credentials'] }}" - no_log: true - when: hostvars['localhost']['telemetry_credentials'] is defined - - - name: Check if VictoriaMetrics is needed for SFM telemetry - ansible.builtin.set_fact: - victoria_metrics_needed: "{{ 'victoria_metrics' in (telemetry_config.telemetry_sources.sfm.collection_targets | default([])) }}" - when: telemetry_config is defined - - - name: Set victoria_deployment_mode - ansible.builtin.set_fact: - victoria_deployment_mode: "cluster" - when: - - victoria_metrics_needed | default(false) | bool - - hostvars['localhost']['victoria_metrics_support'] | default(false) | bool - - - name: Deploy VictoriaMetrics if needed and not already deployed - ansible.builtin.include_role: - name: deploy_victoria - when: - - victoria_metrics_needed | default(false) | bool - - hostvars['localhost']['victoria_metrics_support'] | default(false) | bool - - - name: Include deploy_sfm role - ansible.builtin.include_role: - name: deploy_sfm diff --git a/src/telemetry/playbooks/rollback/sources/rollback_sfm.yml b/src/telemetry/playbooks/rollback/sources/rollback_sfm.yml deleted file mode 100644 index 3adb29f965..0000000000 --- a/src/telemetry/playbooks/rollback/sources/rollback_sfm.yml +++ /dev/null @@ -1,25 +0,0 @@ ---- -# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - ---- -- name: Rollback SFM Telemetry - hosts: kube_vip_group - gather_facts: true - any_errors_fatal: true - - tasks: - - name: Placeholder task for SFM telemetry rollback - ansible.builtin.debug: - msg: "SFM telemetry rollback - to be implemented" diff --git a/src/telemetry/playbooks/telemetry.yml b/src/telemetry/playbooks/telemetry.yml index 840b93a98d..2ac03c3fc9 100644 --- a/src/telemetry/playbooks/telemetry.yml +++ b/src/telemetry/playbooks/telemetry.yml @@ -33,7 +33,7 @@ # Granular cleanup tags (opt-in — requires explicit --tags): # cleanup_kafka, cleanup_victoria_metrics, cleanup_victoria_logs, # cleanup_idrac, cleanup_ldms, cleanup_ome, cleanup_powerscale, -# cleanup_ufm, cleanup_vast, cleanup_sfm +# cleanup_ufm, cleanup_vast # # Internal steps (always): # Step 0: Setup — load env vars (omnia.env), validate host, derive paths, create dirs @@ -106,7 +106,7 @@ - cleanup_powerscale - cleanup_ufm - cleanup_vast - - cleanup_sfm + # ========================================================================= # FLOW: upgrade — Upgrade telemetry (opt-in only, placeholder) @@ -135,6 +135,15 @@ - never - external_kafka +# ========================================================================= +# UTILITY: external_victoria — Fetch Victoria connection details (opt-in) +# ========================================================================= +- name: "Utility | Fetch external Victoria connection details" + ansible.builtin.import_playbook: utils/external_victoria_connect.yml + tags: + - never + - external_victoria + # --------------------------------------------------------------------------- # Summary — Display telemetry status file location # --------------------------------------------------------------------------- diff --git a/src/telemetry/playbooks/utils/external_victoria_connect.yml b/src/telemetry/playbooks/utils/external_victoria_connect.yml new file mode 100644 index 0000000000..80de1b1b25 --- /dev/null +++ b/src/telemetry/playbooks/utils/external_victoria_connect.yml @@ -0,0 +1,39 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +--- +# external_victoria_connect.yml — Fetch Victoria external connection details +# +# Extracts VictoriaMetrics and VictoriaLogs LoadBalancer endpoints, TLS +# certificates, VLAgent syslog target, and SFM/PowerScale integration +# instructions from the deployed telemetry cluster. +# +# Usage: +# ansible-playbook playbooks/telemetry.yml --tags external_victoria +# +# Output: +# {{ output_project_dir }}/external_victoria/ +# ├── ca.crt — Victoria TLS CA certificate (if TLS enabled) +# └── external_victoria_connect_details.yml — Connection details YAML + +# Step 1: Load config, resolve kube_vip, add to inventory +- name: "External Victoria | Bootstrap: load config and resolve kube_vip" + ansible.builtin.import_playbook: ../deploy/telemetry_prereq.yml + +# Step 2: Fetch Victoria connection details from kube_vip +- name: "External Victoria | Fetch connection details from K8s cluster" + hosts: kube_vip_group + connection: ssh + gather_facts: false + roles: + - external_victoria_connect diff --git a/src/telemetry/roles/cleanup/tasks/main.yml b/src/telemetry/roles/cleanup/tasks/main.yml index 40c5b2c4d9..c68fd32fc7 100644 --- a/src/telemetry/roles/cleanup/tasks/main.yml +++ b/src/telemetry/roles/cleanup/tasks/main.yml @@ -30,7 +30,7 @@ # tasks_from: powerscale # Cleanup PowerScale # tasks_from: ufm # Cleanup UFM # tasks_from: vast # Cleanup VAST -# tasks_from: sfm # Cleanup SFM + # # Calling main.yml directly is a no-op (this file). # ============================================================================= diff --git a/src/telemetry/roles/cleanup/tasks/sfm.yml b/src/telemetry/roles/cleanup/tasks/sfm.yml deleted file mode 100644 index abb31ddb95..0000000000 --- a/src/telemetry/roles/cleanup/tasks/sfm.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. ---- - -- name: "SFM | Delete resources" - ansible.builtin.command: > - kubectl -n {{ telemetry_namespace }} delete {{ item.type }} {{ item.name }} - --ignore-not-found=true --wait=false - loop: "{{ sfm_resources }}" - loop_control: - label: "{{ item.type }}/{{ item.name }}" - changed_when: false - failed_when: false - -- name: "SFM | Force delete pods" - ansible.builtin.command: > - kubectl -n {{ telemetry_namespace }} delete pod -l {{ sfm_pod_label }} - --grace-period={{ cleanup_resource_grace_period }} --force --ignore-not-found=true - changed_when: false - failed_when: false - -- name: "SFM | Cleanup complete" - ansible.builtin.debug: - msg: "SFM telemetry cleanup completed" diff --git a/src/telemetry/roles/cleanup/vars/main.yml b/src/telemetry/roles/cleanup/vars/main.yml index beef3cc1ce..57a44c5baa 100644 --- a/src/telemetry/roles/cleanup/vars/main.yml +++ b/src/telemetry/roles/cleanup/vars/main.yml @@ -288,15 +288,4 @@ vast_resources: - { type: "endpoints", name: "vast-external" } - { type: "service", name: "vast-external" } - { type: "vmservicescrape", name: "vast-storage-metrics" } - vast_pod_label: "app=vast-external" - -# ============================================================================= -# SFM cleanup resources -# ============================================================================= -sfm_resources: - - { type: "deployment", name: "sfm-exporter" } - - { type: "service", name: "sfm-exporter" } - - { type: "configmap", name: "sfm-scrape-config" } - -sfm_pod_label: "app=sfm-telemetry" diff --git a/src/telemetry/roles/common/tasks/write_telemetry_status.yml b/src/telemetry/roles/common/tasks/write_telemetry_status.yml index 7a7fad6079..1b6073704d 100644 --- a/src/telemetry/roles/common/tasks/write_telemetry_status.yml +++ b/src/telemetry/roles/common/tasks/write_telemetry_status.yml @@ -16,10 +16,10 @@ # Write telemetry_status.yml to the output project directory # ============================================================================ # Writes a single authoritative status file after deployment completes. -# Pattern follows image_build_manager/roles/build_os_images/tasks/write_build_status.yml +# Status values: passed, failed, skipped # # Expected to run on localhost after all deployment tasks complete. -# Requires: telemetry_pods (from kubectl get pods on kube_vip) +# Deployment results are collected from hostvars set during Phase 4. - name: Set output paths ansible.builtin.set_fact: @@ -31,118 +31,83 @@ state: directory mode: "0755" -# -- Gather sink deployment state from kube_vip hostvars -------------------- -- name: Gather sink deployment state - vars: - _kv: "{{ hostvars[kube_vip] }}" +# -- Aggregate deployment results from Phase 4 hostvars ---------------------- +- name: Aggregate sink deployment results ansible.builtin.set_fact: - _kafka_deployed: >- - {{ _kv['kafka_deployed'] - | default(_kv['sink_kafka_result'] | default('') == 'DEPLOYED') - | bool }} - _victoria_metrics_deployed: >- - {{ _kv['victoria_metrics_deployed'] - | default(_kv['sink_victoria_metrics_result'] | default('') == 'DEPLOYED') - | bool }} - _victoria_logs_deployed: >- - {{ _kv['victoria_logs_deployed'] - | default(_kv['sink_victoria_logs_result'] | default('') == 'DEPLOYED') - | bool }} - when: kube_vip is defined + _sink_results: + kafka: "{{ hostvars['localhost']['_deploy_result_kafka'] | default('skipped') }}" + victoria_metrics: "{{ hostvars['localhost']['_deploy_result_victoria_metrics'] | default('skipped') }}" + victoria_logs: "{{ hostvars['localhost']['_deploy_result_victoria_logs'] | default('skipped') }}" -# -- Gather source/bridge flags from telemetry_config ---------------------- -- name: Gather source and bridge deployment flags +- name: Aggregate source deployment results ansible.builtin.set_fact: - _ome_deployed: >- - {{ - (telemetry_config.telemetry_bridges.vector_ome.metrics_enabled | default(false) | bool) or - (telemetry_config.telemetry_bridges.vector_ome.logs_enabled | default(false) | bool) - }} - _vector_ldms_deployed: "{{ telemetry_config.telemetry_bridges.vector_ldms.metrics_enabled | default(false) | bool }}" - _deployed_sources: - idrac: "{{ telemetry_config.telemetry_sources.idrac.metrics_enabled | default(false) | bool }}" - ldms: "{{ telemetry_config.telemetry_sources.ldms.metrics_enabled | default(false) | bool }}" - ome: >- - {{ - (telemetry_config.telemetry_sources.ome.metrics_enabled | default(false) | bool) or - (telemetry_config.telemetry_sources.ome.logs_enabled | default(false) | bool) - }} - powerscale: "{{ telemetry_config.telemetry_sources.powerscale.metrics_enabled | default(false) | bool }}" - ufm: "{{ telemetry_config.telemetry_sources.ufm.metrics_enabled | default(false) | bool }}" - vast: "{{ telemetry_config.telemetry_sources.vast.metrics_enabled | default(false) | bool }}" - sfm: "{{ telemetry_config.telemetry_sources.sfm.metrics_enabled | default(false) | bool }}" - skyway: "{{ telemetry_config.telemetry_sources.skyway.metrics_enabled | default(false) | bool }}" - powervault: "{{ telemetry_config.telemetry_sources.powervault.metrics_enabled | default(false) | bool }}" + _source_results: + idrac_metrics: "{{ hostvars['localhost']['_deploy_result_idrac_metrics'] | default('skipped') }}" + ldms_metrics: "{{ hostvars['localhost']['_deploy_result_ldms_metrics'] | default('skipped') }}" + powerscale_metrics: "{{ hostvars['localhost']['_deploy_result_powerscale_metrics'] | default('skipped') }}" + powerscale_logs: "{{ hostvars['localhost']['_deploy_result_powerscale_logs'] | default('skipped') }}" + ufm_metrics: "{{ hostvars['localhost']['_deploy_result_ufm_metrics'] | default('skipped') }}" + ufm_logs: "{{ hostvars['localhost']['_deploy_result_ufm_logs'] | default('skipped') }}" + vast_metrics: "{{ hostvars['localhost']['_deploy_result_vast_metrics'] | default('skipped') }}" + vast_logs: "{{ hostvars['localhost']['_deploy_result_vast_logs'] | default('skipped') }}" + ome_metrics: "{{ hostvars['localhost']['_deploy_result_ome_metrics'] | default('skipped') }}" + ome_logs: "{{ hostvars['localhost']['_deploy_result_ome_logs'] | default('skipped') }}" -# -- Kafka topics: derive from config collection_targets ------------------- -- name: Derive Kafka topics from enabled sources +- name: Aggregate bridge deployment results ansible.builtin.set_fact: - _kafka_topics: >- - {%- set topics = [] -%} - {%- if telemetry_config.telemetry_sources.idrac.metrics_enabled | default(false) | bool - and 'kafka' in (telemetry_config.telemetry_sources.idrac.collection_targets | default([])) -%} - {%- set _ = topics.append('idrac') -%} - {%- endif -%} - {%- if telemetry_config.telemetry_sources.ldms.metrics_enabled | default(false) | bool - and 'kafka' in (telemetry_config.telemetry_sources.ldms.collection_targets | default([])) -%} - {%- set _ = topics.append('ldms') -%} - {%- endif -%} - {{ topics }} + _bridge_results: + vector_ldms: "{{ hostvars['localhost']['_deploy_result_vector_ldms'] | default('skipped') }}" + vector_ome: "{{ hostvars['localhost']['_deploy_result_vector_ome'] | default('skipped') }}" -# -- iDRAC BMC count ------------------------------------------------------- -- name: Count configured BMC servers +# -- Determine overall deployment status ------------------------------------- +- name: Determine overall deployment status vars: - _bmc_ips: >- - {{ telemetry_config.idrac_telemetry_configurations.oim_bmc_ips - | default({}) }} - ansible.builtin.set_fact: - _idrac_bmc_count: >- - {{ _bmc_ips | dict2items - | selectattr('value', 'ne', '') | list | length }} - when: >- - telemetry_config.telemetry_sources.idrac.metrics_enabled - | default(false) | bool - failed_when: false - -# -- LDMS sampler node count ----------------------------------------------- -- name: Count LDMS sampler nodes + _all_results: >- + {{ [_sink_results.kafka, _sink_results.victoria_metrics, _sink_results.victoria_logs] + + [_source_results.idrac_metrics, _source_results.ldms_metrics] + + [_source_results.powerscale_metrics, _source_results.powerscale_logs] + + [_source_results.ufm_metrics, _source_results.ufm_logs] + + [_source_results.vast_metrics, _source_results.vast_logs] + + [_source_results.ome_metrics, _source_results.ome_logs] + + [_bridge_results.vector_ldms, _bridge_results.vector_ome] }} + _has_failed: "{{ 'failed' in _all_results }}" + _has_passed: "{{ 'passed' in _all_results }}" ansible.builtin.set_fact: - _ldms_sampler_nodes: "{{ (telemetry_config.ldms_configurations.sampler_plugins | default([]) | length) }}" - when: telemetry_config.telemetry_sources.ldms.metrics_enabled | default(false) | bool - failed_when: false + _deployment_status: >- + {% if _has_failed %}failed{%- elif _has_passed %}passed{%- else %}skipped{% endif %} -# -- Pod counts from kubectl output ----------------------------------------- -- name: Count pods from telemetry_pods output - vars: - _status_pod_lines: "{{ hostvars[kube_vip]['telemetry_pods'].stdout_lines | default([]) }}" - ansible.builtin.set_fact: - _pod_total: "{{ _status_pod_lines | length }}" - _pod_ready: "{{ _status_pod_lines | select('search', '\\s+Running\\s+') | list | length }}" - _pod_failed: "{{ _status_pod_lines | select('search', 'CrashLoopBackOff|Error|ImagePullBackOff|ErrImagePull') | list | length }}" - _failed_pod_names: "{{ _status_pod_lines | select('search', 'CrashLoopBackOff|Error|ImagePullBackOff|ErrImagePull') | map('split') | map('first') | list }}" - when: - - kube_vip is defined - - hostvars[kube_vip]['telemetry_pods'] is defined - -# -- Overall status --------------------------------------------------------- -- name: Determine overall status - ansible.builtin.set_fact: - _overall_status: >- - {% if (_pod_failed | default(0) | int) > 0 %}failed{% elif (_pod_ready | default(0) | int) > 0 %}success{% else %}partial{% endif %} - -# -- Write the template ----------------------------------------------------- -- name: Write telemetry_status.yml (latest) +# -- Write the template ------------------------------------------------------ +- name: Write telemetry_status.yml ansible.builtin.template: src: "{{ role_path }}/templates/telemetry_status.yml.j2" dest: "{{ _output_base }}/telemetry_status.yml" mode: "0644" -- name: Display telemetry status output location +# -- Display summary --------------------------------------------------------- +- name: Display telemetry deployment status ansible.builtin.debug: - msg: - - "Status: {{ _output_base }}/telemetry_status.yml" - - >- - Overall: {{ _overall_status | trim }} | - Pods: {{ _pod_total | default(0) }} total, - {{ _pod_ready | default(0) }} ready, - {{ _pod_failed | default(0) }} failed + msg: "{{ _status_lines }}" + vars: + _status_lines: + - "======================================================" + - "Telemetry Deployment Status: {{ _deployment_status | upper | trim }}" + - "======================================================" + - "Output: {{ _output_base }}/telemetry_status.yml" + - "" + - "Sinks:" + - " kafka: {{ _sink_results.kafka }}" + - " victoria_metrics: {{ _sink_results.victoria_metrics }}" + - " victoria_logs: {{ _sink_results.victoria_logs }}" + - "" + - "Sources:" + - " idrac: metrics={{ _source_results.idrac_metrics }}" + - " ldms: metrics={{ _source_results.ldms_metrics }}" + - " powerscale: metrics={{ _source_results.powerscale_metrics }}, logs={{ _source_results.powerscale_logs }}" + - " ufm: metrics={{ _source_results.ufm_metrics }}, logs={{ _source_results.ufm_logs }}" + - " vast: metrics={{ _source_results.vast_metrics }}, logs={{ _source_results.vast_logs }}" + - " ome: metrics={{ _source_results.ome_metrics }}, logs={{ _source_results.ome_logs }}" + - "" + - "Bridges:" + - " vector_ldms: {{ _bridge_results.vector_ldms }}" + - " vector_ome: {{ _bridge_results.vector_ome }}" + - "======================================================" diff --git a/src/telemetry/roles/common/templates/telemetry_status.yml.j2 b/src/telemetry/roles/common/templates/telemetry_status.yml.j2 index e71d3a4050..2bcb0e38ea 100644 --- a/src/telemetry/roles/common/templates/telemetry_status.yml.j2 +++ b/src/telemetry/roles/common/templates/telemetry_status.yml.j2 @@ -1,11 +1,11 @@ --- -# telemetry_status.yml — telemetry domain output +# telemetry_status.yml — Telemetry deployment status # Producer: write_telemetry_status (Phase 5 of deploy) # Consumer: omnia-cli check, operators domain: "telemetry" project_name: "{{ project_name | default('project_default') }}" -overall_status: "{{ _overall_status }}" +deployment_status: "{{ _deployment_status }}" generated_at: "{{ ansible_date_time.iso8601 }}" namespace: "{{ telemetry_namespace }}" kube_vip: "{{ kube_vip | default('') }}" @@ -13,84 +13,30 @@ kube_vip: "{{ kube_vip | default('') }}" packages: install_mode: "{{ telemetry_packages_config.install_mode | default('offline') }}" repo_url: "{{ telemetry_packages_config.repo_url | default('') }}" - container_registry: "{{ telemetry_registry.host | default('') }}" sinks: - kafka: - deployed: {{ _kafka_deployed | default(false) | lower }} -{% if _kafka_deployed | default(false) | bool %} - version: "{{ telemetry_registry.images.kafka.broker | default('') }}" -{% if _kafka_topics | default([]) | length > 0 %} - topics_created: -{% for t in _kafka_topics %} - - "{{ t }}" -{% endfor %} -{% else %} - topics_created: [] -{% endif %} -{% endif %} - victoria_metrics: - deployed: {{ _victoria_metrics_deployed | default(false) | lower }} -{% if _victoria_metrics_deployed | default(false) | bool %} - version: "{{ telemetry_registry.images.victoria.vmstorage | default('') }}" - deployment_mode: "cluster" - operator: "{{ telemetry_registry.images.victoria.operator | default('') }}" -{% endif %} - victoria_logs: - deployed: {{ _victoria_logs_deployed | default(false) | lower }} -{% if _victoria_logs_deployed | default(false) | bool %} - version: "{{ telemetry_registry.images.victoria.vlstorage | default('') }}" -{% endif %} + kafka: "{{ _sink_results.kafka | default('skipped') }}" + victoria_metrics: "{{ _sink_results.victoria_metrics | default('skipped') }}" + victoria_logs: "{{ _sink_results.victoria_logs | default('skipped') }}" sources: idrac: - deployed: {{ (_deployed_sources.idrac | default(false)) | lower }} -{% if _deployed_sources.idrac | default(false) | bool %} - version: "{{ telemetry_registry.images.idrac.telemetry_receiver | default('') }}" - bmc_servers_configured: {{ _idrac_bmc_count | default(0) }} -{% endif %} + metrics: "{{ _source_results.idrac_metrics | default('skipped') }}" ldms: - deployed: {{ (_deployed_sources.ldms | default(false)) | lower }} -{% if _deployed_sources.ldms | default(false) | bool %} - version: "{{ telemetry_registry.images.ldms.sampler | default('') }}" - sampler_nodes: {{ _ldms_sampler_nodes | default(0) }} -{% endif %} - ome: - deployed: {{ (_deployed_sources.ome | default(false)) | lower }} + metrics: "{{ _source_results.ldms_metrics | default('skipped') }}" powerscale: - deployed: {{ (_deployed_sources.powerscale | default(false)) | lower }} + metrics: "{{ _source_results.powerscale_metrics | default('skipped') }}" + logs: "{{ _source_results.powerscale_logs | default('skipped') }}" ufm: - deployed: {{ (_deployed_sources.ufm | default(false)) | lower }} + metrics: "{{ _source_results.ufm_metrics | default('skipped') }}" + logs: "{{ _source_results.ufm_logs | default('skipped') }}" vast: - deployed: {{ (_deployed_sources.vast | default(false)) | lower }} - sfm: - deployed: {{ (_deployed_sources.sfm | default(false)) | lower }} - skyway: - deployed: {{ (_deployed_sources.skyway | default(false)) | lower }} - powervault: - deployed: {{ (_deployed_sources.powervault | default(false)) | lower }} + metrics: "{{ _source_results.vast_metrics | default('skipped') }}" + logs: "{{ _source_results.vast_logs | default('skipped') }}" + ome: + metrics: "{{ _source_results.ome_metrics | default('skipped') }}" + logs: "{{ _source_results.ome_logs | default('skipped') }}" bridges: - vector_ome: - deployed: {{ _ome_deployed | default(false) | lower }} -{% if _ome_deployed | default(false) | bool %} - version: "{{ telemetry_registry.images.vector.vector | default('') }}" - metrics_enabled: {{ (telemetry_config.telemetry_bridges.vector_ome.metrics_enabled | default(false)) | lower }} - logs_enabled: {{ (telemetry_config.telemetry_bridges.vector_ome.logs_enabled | default(false)) | lower }} -{% endif %} - vector_ldms: - deployed: {{ _vector_ldms_deployed | default(false) | lower }} -{% if _vector_ldms_deployed | default(false) | bool %} - version: "{{ telemetry_registry.images.vector.vector | default('') }}" -{% endif %} - -pods: - total: {{ _pod_total | default(0) }} - ready: {{ _pod_ready | default(0) }} - failed: {{ _pod_failed | default(0) }} -{% if (_pod_failed | default(0) | int) > 0 %} - failed_pods: -{% for pod in _failed_pod_names | default([]) %} - - "{{ pod }}" -{% endfor %} -{% endif %} + vector_ldms: "{{ _bridge_results.vector_ldms | default('skipped') }}" + vector_ome: "{{ _bridge_results.vector_ome | default('skipped') }}" diff --git a/src/telemetry/roles/deploy_idrac_telemetry/tasks/enable.yml b/src/telemetry/roles/deploy_idrac_telemetry/tasks/enable.yml index d822a086b8..a4b947bcfd 100644 --- a/src/telemetry/roles/deploy_idrac_telemetry/tasks/enable.yml +++ b/src/telemetry/roles/deploy_idrac_telemetry/tasks/enable.yml @@ -66,5 +66,4 @@ - telemetry_config.idrac_telemetry_configurations.bmc_group_data_path is defined - telemetry_config.idrac_telemetry_configurations.bmc_group_data_path | length > 0 -- name: Update telemetry status - ansible.builtin.include_tasks: update_status.yml +## Status is now collected centrally in deploy.yml Phase 4. diff --git a/src/telemetry/roles/deploy_ldms/tasks/enable.yml b/src/telemetry/roles/deploy_ldms/tasks/enable.yml index b94bc5b321..8893237c3e 100644 --- a/src/telemetry/roles/deploy_ldms/tasks/enable.yml +++ b/src/telemetry/roles/deploy_ldms/tasks/enable.yml @@ -66,5 +66,4 @@ - name: Verify LDMS telemetry ansible.builtin.include_tasks: verify_ldms.yml -- name: Update telemetry status - ansible.builtin.include_tasks: update_status.yml +## Status is now collected centrally in deploy.yml Phase 4. diff --git a/src/telemetry/roles/deploy_sfm/README.md b/src/telemetry/roles/deploy_sfm/README.md deleted file mode 100644 index b4682b949f..0000000000 --- a/src/telemetry/roles/deploy_sfm/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# deploy_sfm - -Deploys SFM (Smart Fabric Manager) telemetry for network fabric monitoring - -## Requirements - -- Ansible >= 2.20 -- RHEL/Rocky Linux 10.x - -## Role Variables - -See `vars/main.yml` and `defaults/main.yml` for configurable variables. - -## Dependencies - -None. - -## Example Playbook - -```yaml -- hosts: localhost - connection: local - roles: - - role: omnia.telemetry.deploy_sfm -``` - -## License - -Apache-2.0 - -## Author Information - -Dell Technologies () diff --git a/src/telemetry/roles/deploy_sfm/tasks/disable.yml b/src/telemetry/roles/deploy_sfm/tasks/disable.yml deleted file mode 100644 index 676fe48cf0..0000000000 --- a/src/telemetry/roles/deploy_sfm/tasks/disable.yml +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. ---- - -# ============================================================================= -# deploy_sfm — Disable SFM telemetry -# ============================================================================= - -- name: Load telemetry configuration - ansible.builtin.include_role: - name: common - tasks_from: load_telemetry_config - -- name: Check if SFM resources exist - ansible.builtin.command: > - kubectl get deployment sfm-exporter -n {{ telemetry_namespace }} - register: sfm_check - changed_when: false - failed_when: false - -- name: Scale down SFM resources - ansible.builtin.command: > - kubectl scale deployment sfm-exporter - -n {{ telemetry_namespace }} --replicas=0 - register: scale_down_result - changed_when: "'scaled' in scale_down_result.stdout" - when: sfm_check.rc == 0 - -- name: Display scale down result - ansible.builtin.debug: - msg: "Scaling down SFM telemetry to 0 replicas" - when: sfm_check.rc == 0 - -- name: SFM resources not found - ansible.builtin.debug: - msg: "SFM telemetry resources not found, nothing to scale down" - when: sfm_check.rc != 0 diff --git a/src/telemetry/roles/deploy_sfm/tasks/enable.yml b/src/telemetry/roles/deploy_sfm/tasks/enable.yml deleted file mode 100644 index a0ea589f40..0000000000 --- a/src/telemetry/roles/deploy_sfm/tasks/enable.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. ---- - -# Deploy SFM (Storage Fabric Manager) Telemetry -# Gated by: telemetry_sources.sfm.metrics_enabled -# -# DATA PIPELINE: -# SFM Prometheus Exporter → vmagent(shared) → victoria_metrics -# -# What Omnia does: -# - Validates SFM endpoint configuration -# - Generates K8s manifests for SFM scrape configuration -# -# What Omnia does NOT do: -# - Omnia does NOT deploy or configure SFM itself - -- name: Set SFM configuration facts - ansible.builtin.set_fact: - _sfm_config: "{{ telemetry_config.telemetry_sources.sfm }}" - -- name: Display SFM configuration - ansible.builtin.debug: - msg: - - "SFM metrics enabled: {{ _sfm_config.metrics_enabled | default(false) }}" - - "SFM collection targets: {{ _sfm_config.collection_targets | default([]) }}" - verbosity: 2 - -# --- Generate SFM deployment manifests --- - -- name: Generate SFM kustomization manifests - ansible.builtin.include_tasks: generate_manifests.yml - when: _sfm_config.metrics_enabled | default(false) | bool - -- name: Display SFM telemetry integration summary - ansible.builtin.debug: - msg: "SFM telemetry manifests generated. Deployment will occur via kubectl apply -k." - verbosity: 2 diff --git a/src/telemetry/roles/deploy_sfm/tasks/generate_manifests.yml b/src/telemetry/roles/deploy_sfm/tasks/generate_manifests.yml deleted file mode 100644 index 545a8f32e8..0000000000 --- a/src/telemetry/roles/deploy_sfm/tasks/generate_manifests.yml +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. ---- - -# Generate SFM telemetry Kustomize manifests to NFS deployments/sfm/ - -- name: Create SFM deployments directory - ansible.builtin.file: - path: "{{ telemetry_deployments_dir }}/sfm" - state: directory - mode: '0755' - -# TODO: Add SFM manifest templates when available -# - sfm-scrape-config.yaml.j2 -# - kustomization.yaml.j2 - -- name: Display SFM manifest generation complete - ansible.builtin.debug: - msg: "SFM telemetry manifests directory created at {{ telemetry_deployments_dir }}/sfm/" - verbosity: 2 diff --git a/src/telemetry/roles/deploy_sfm/tasks/main.yml b/src/telemetry/roles/deploy_sfm/tasks/main.yml deleted file mode 100644 index 262b155389..0000000000 --- a/src/telemetry/roles/deploy_sfm/tasks/main.yml +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. ---- - -# ============================================================================= -# deploy_sfm — Orchestrator -# ============================================================================= -# Auto-determines state from telemetry_config.yml: -# telemetry_sources.sfm.metrics_enabled -# Note: SFM source not yet in telemetry_config.yml, defaults to false. -# ============================================================================= - -- name: Load telemetry configuration - ansible.builtin.include_role: - name: common - tasks_from: load_telemetry_config - when: telemetry_config is not defined - -- name: Determine SFM telemetry state from config - ansible.builtin.set_fact: - sfm_enabled: "{{ telemetry_config.telemetry_sources.sfm.metrics_enabled | default(false) | bool }}" - when: telemetry_config is defined - -- name: Enable SFM telemetry - ansible.builtin.include_tasks: enable.yml - when: sfm_enabled | default(false) | bool - -- name: Disable SFM telemetry - ansible.builtin.include_tasks: disable.yml - when: not (sfm_enabled | default(false) | bool) diff --git a/src/telemetry/roles/deploy_sfm/vars/main.yml b/src/telemetry/roles/deploy_sfm/vars/main.yml deleted file mode 100644 index bc31df4ae3..0000000000 --- a/src/telemetry/roles/deploy_sfm/vars/main.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. ---- - -# ============================================================================= -# SFM Telemetry Role Variables -# ============================================================================= - -# Deploy directory on NFS share -telemetry_deploy_dir: "{{ cluster_mount }}/telemetry" -telemetry_deployments_dir: "{{ telemetry_deploy_dir }}/deployments" -telemetry_namespace: "telemetry" diff --git a/src/telemetry/roles/external_victoria_connect/README.md b/src/telemetry/roles/external_victoria_connect/README.md new file mode 100644 index 0000000000..abbae5e594 --- /dev/null +++ b/src/telemetry/roles/external_victoria_connect/README.md @@ -0,0 +1,50 @@ +# External Victoria Connect + +Fetches VictoriaMetrics and VictoriaLogs external connection details (endpoints, TLS certificates, syslog target) from the Kubernetes cluster for external client integration (e.g., SFM, PowerScale). + +## Requirements + +- Telemetry must be deployed (`ansible-playbook telemetry.yml --tags deploy`) +- VictoriaMetrics pods must be running in the `telemetry` namespace +- SSH access to `kube_vip` from the OIM host + +## Output + +Files are written to `{{ output_project_dir }}/external_victoria`: + +| File | Description | +|------|-------------| +| `ca.crt` | Victoria TLS CA certificate (only if TLS enabled) | +| `external_victoria_connect_details.yml` | Connection details YAML | + +The connection details file includes: + +| Section | Contents | +|---------|----------| +| `victoria_metrics` | vminsert/vmselect endpoints, write/query URLs, SFM notes | +| `victoria_logs` | vlinsert/vlselect endpoints (if deployed) | +| `vlagent` | Syslog endpoint for PowerScale forwarding (if deployed) | +| `powerscale` | ISI audit commands for syslog configuration | + +## Usage + +```bash +ansible-playbook playbooks/telemetry.yml --tags external_victoria +``` + +## Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `victoria_namespace` | `telemetry` | Kubernetes namespace | +| `vm_vminsert_service` | `vminsert-victoria-cluster` | vminsert LoadBalancer service | +| `vm_vmselect_service` | `vmselect-victoria-cluster` | vmselect LoadBalancer service | +| `vm_vminsert_port` | `8480` | vminsert port | +| `vm_vmselect_port` | `8481` | vmselect port | +| `vl_vlinsert_service` | `vlinsert-victoria-logs-cluster` | vlinsert LoadBalancer service | +| `vl_vlselect_service` | `vlselect-victoria-logs-cluster` | vlselect LoadBalancer service | +| `vl_vlagent_service` | `vlagent` | VLAgent syslog LoadBalancer service | + +## Dependencies + +None (uses `telemetry_prereq.yml` for config loading and kube_vip resolution). diff --git a/src/telemetry/roles/deploy_sfm/meta/main.yml b/src/telemetry/roles/external_victoria_connect/meta/main.yml similarity index 75% rename from src/telemetry/roles/deploy_sfm/meta/main.yml rename to src/telemetry/roles/external_victoria_connect/meta/main.yml index d32984b558..aca16addae 100644 --- a/src/telemetry/roles/deploy_sfm/meta/main.yml +++ b/src/telemetry/roles/external_victoria_connect/meta/main.yml @@ -12,21 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. --- + galaxy_info: - role_name: deploy_sfm - namespace: omnia - author: Dell Technologies - description: "Deploys SFM (Smart Fabric Manager) telemetry for network fabric monitoring" + author: Dell Inc. + description: >- + Fetches VictoriaMetrics and VictoriaLogs external connection details + (endpoints, TLS certs, syslog target) from the Kubernetes cluster + for external client integration (e.g., SFM, PowerScale). license: Apache-2.0 min_ansible_version: "2.20" platforms: - name: EL versions: - "10" - galaxy_tags: - - telemetry - - sfm - - network - - omnia dependencies: [] diff --git a/src/telemetry/roles/external_victoria_connect/tasks/main.yml b/src/telemetry/roles/external_victoria_connect/tasks/main.yml new file mode 100644 index 0000000000..b1cf08a0ba --- /dev/null +++ b/src/telemetry/roles/external_victoria_connect/tasks/main.yml @@ -0,0 +1,396 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +--- + +# ============================================================================= +# external_victoria_connect — Fetch Victoria connection details for external clients +# +# This role runs on kube_vip to extract: +# - VictoriaMetrics vminsert/vmselect LoadBalancer endpoints +# - VictoriaLogs vlinsert/vlselect LoadBalancer endpoints +# - VLAgent syslog LoadBalancer endpoint (for PowerScale) +# - TLS CA certificate (if TLS is enabled) +# - SFM Prometheus remote-write integration instructions +# - PowerScale syslog forwarding commands +# +# Output: {{ victoria_output_dir }}/ +# ├── ca.crt (if TLS enabled) +# └── external_victoria_connect_details.yml +# ============================================================================= + +- name: Validate kube_vip connectivity + block: + - name: Wait for kube_vip connection + ansible.builtin.wait_for_connection: + timeout: 30 + rescue: + - name: Fail when kube_vip is not reachable + ansible.builtin.fail: + msg: "{{ victoria_err_kube_vip_unreachable }}" + +- name: Check kubectl availability + ansible.builtin.command: kubectl version --client=true + register: _kubectl_check + changed_when: false + failed_when: _kubectl_check.rc != 0 + +- name: Clean output directory for fresh export + ansible.builtin.file: + path: "{{ victoria_output_dir }}" + state: absent + delegate_to: localhost + connection: local + run_once: true + +# ============================================================================= +# Validate VictoriaMetrics cluster health +# ============================================================================= + +- name: Get VictoriaMetrics pod status (vminsert, vmselect, vmstorage) + ansible.builtin.command: >- + kubectl get pods -n {{ victoria_namespace }} + -l 'app.kubernetes.io/instance=victoria-cluster' + -o json + register: _vm_pods_json + changed_when: false + failed_when: _vm_pods_json.rc != 0 + +- name: Parse VictoriaMetrics pod data + ansible.builtin.set_fact: + _vm_pod_items: "{{ (_vm_pods_json.stdout | from_json)['items'] | default([]) }}" + +- name: Fail if no VictoriaMetrics pods found + ansible.builtin.fail: + msg: "{{ victoria_err_no_vm_pods }}" + when: (_vm_pod_items | length) == 0 + +- name: Fail if VictoriaMetrics pods are not Running + ansible.builtin.fail: + msg: "{{ victoria_err_vm_pods_not_running }}" + when: >- + (_vm_pod_items + | selectattr('status.phase', 'ne', 'Running') + | list | length) > 0 + +- name: Capture VictoriaMetrics pod status (wide) + ansible.builtin.command: >- + kubectl get pods -n {{ victoria_namespace }} + -l 'app.kubernetes.io/instance=victoria-cluster' + -o wide + register: _vm_pod_status_wide + changed_when: false + +# ============================================================================= +# Extract VictoriaMetrics external endpoints +# ============================================================================= + +- name: Get vminsert LoadBalancer external IP + ansible.builtin.command: >- + kubectl get svc {{ vm_vminsert_service }} -n {{ victoria_namespace }} + -o jsonpath='{.status.loadBalancer.ingress[0].ip}' + register: _vminsert_lb_ip + changed_when: false + failed_when: _vminsert_lb_ip.rc != 0 + +- name: Set vminsert external endpoint + ansible.builtin.set_fact: + _vminsert_ip: "{{ _vminsert_lb_ip.stdout | trim }}" + +- name: Fail when vminsert external IP is not available + ansible.builtin.fail: + msg: "{{ victoria_err_vminsert_ip_missing }}" + when: _vminsert_ip | trim | length == 0 + +- name: Get vmselect LoadBalancer external IP + ansible.builtin.command: >- + kubectl get svc {{ vm_vmselect_service }} -n {{ victoria_namespace }} + -o jsonpath='{.status.loadBalancer.ingress[0].ip}' + register: _vmselect_lb_ip + changed_when: false + failed_when: _vmselect_lb_ip.rc != 0 + +- name: Set vmselect external endpoint + ansible.builtin.set_fact: + _vmselect_ip: "{{ _vmselect_lb_ip.stdout | trim }}" + +- name: Fail when vmselect external IP is not available + ansible.builtin.fail: + msg: "{{ victoria_err_vmselect_ip_missing }}" + when: _vmselect_ip | trim | length == 0 + +# ============================================================================= +# Determine protocol (HTTP vs HTTPS) +# ============================================================================= + +- name: Check if VictoriaMetrics TLS secret exists + ansible.builtin.command: >- + kubectl get secret {{ victoria_tls_secret }} -n {{ victoria_namespace }} + -o name + register: _vm_tls_check + changed_when: false + failed_when: false + +- name: Set TLS availability flag + ansible.builtin.set_fact: + _vm_tls_enabled: "{{ _vm_tls_check.rc == 0 }}" + +- name: Set protocol based on TLS + ansible.builtin.set_fact: + _vm_scheme: "{{ 'https' if _vm_tls_enabled | bool else 'http' }}" + +# ============================================================================= +# Extract TLS certificate (if enabled) +# ============================================================================= + +- name: Extract TLS CA certificate + when: _vm_tls_enabled | bool + block: + - name: Read Victoria TLS CA certificate from K8s secret + ansible.builtin.command: >- + kubectl get secret {{ victoria_tls_secret }} -n {{ victoria_namespace }} + -o jsonpath='{.data.ca\.crt}' + register: _victoria_ca_b64 + changed_when: false + failed_when: _victoria_ca_b64.rc != 0 or (_victoria_ca_b64.stdout | trim | length == 0) + no_log: true + + - name: Ensure output directory exists (for cert) + ansible.builtin.file: + path: "{{ victoria_output_dir }}" + state: directory + mode: "0755" + delegate_to: localhost + connection: local + run_once: true + + - name: Write CA certificate file + ansible.builtin.copy: + content: "{{ _victoria_ca_b64.stdout | b64decode }}" + dest: "{{ victoria_output_dir }}/ca.crt" + mode: "0600" + delegate_to: localhost + connection: local + run_once: true + no_log: true + +# ============================================================================= +# Extract VictoriaLogs external endpoints +# ============================================================================= + +- name: Check if vlinsert service exists + ansible.builtin.command: >- + kubectl get svc {{ vl_vlinsert_service }} -n {{ victoria_namespace }} + -o jsonpath='{.status.loadBalancer.ingress[0].ip}' + register: _vlinsert_lb_ip + changed_when: false + failed_when: false + +- name: Set VictoriaLogs availability + ansible.builtin.set_fact: + _vl_available: "{{ _vlinsert_lb_ip.rc == 0 and (_vlinsert_lb_ip.stdout | trim | length > 0) }}" + _vlinsert_ip: "{{ _vlinsert_lb_ip.stdout | trim | default('') }}" + +- name: Get vlselect LoadBalancer external IP + ansible.builtin.command: >- + kubectl get svc {{ vl_vlselect_service }} -n {{ victoria_namespace }} + -o jsonpath='{.status.loadBalancer.ingress[0].ip}' + register: _vlselect_lb_ip + changed_when: false + failed_when: false + when: _vl_available | bool + +- name: Set vlselect endpoint + ansible.builtin.set_fact: + _vlselect_ip: "{{ _vlselect_lb_ip.stdout | trim | default('') }}" + when: _vl_available | bool + +# ============================================================================= +# Extract VLAgent syslog endpoint +# ============================================================================= + +- name: Get VLAgent LoadBalancer external IP + ansible.builtin.command: >- + kubectl get svc {{ vl_vlagent_service }} -n {{ victoria_namespace }} + -o jsonpath='{.status.loadBalancer.ingress[0].ip}' + register: _vlagent_lb_ip + changed_when: false + failed_when: false + +- name: Set VLAgent availability + ansible.builtin.set_fact: + _vlagent_available: "{{ _vlagent_lb_ip.rc == 0 and (_vlagent_lb_ip.stdout | trim | length > 0) }}" + _vlagent_ip: "{{ _vlagent_lb_ip.stdout | trim | default('') }}" + +# ============================================================================= +# Build connection details +# ============================================================================= + +- name: Build VictoriaMetrics connection details + ansible.builtin.set_fact: + _vm_details: + deployment_mode: "cluster" + namespace: "{{ victoria_namespace }}" + base_url: "{{ _vm_scheme }}://{{ _vminsert_ip }}:{{ vm_vminsert_port }}" + endpoints: + vminsert: + host: "{{ _vminsert_ip }}" + port: "{{ vm_vminsert_port }}" + write_endpoint: "{{ _vm_scheme }}://{{ _vminsert_ip }}:{{ vm_vminsert_port }}/insert/0/prometheus/api/v1/write" + vmselect: + host: "{{ _vmselect_ip }}" + port: "{{ vm_vmselect_port }}" + query_endpoint: "{{ _vm_scheme }}://{{ _vmselect_ip }}:{{ vm_vmselect_port }}/select/0/prometheus/api/v1/query" + ui_url: "{{ _vm_scheme }}://{{ _vmselect_ip }}:{{ vm_vmselect_port }}/select/0/vmui" + tls: + ca_crt: "{{ (victoria_output_dir ~ '/ca.crt') if (_vm_tls_enabled | bool) else 'N/A (TLS disabled)' }}" + pod_status: "{{ _vm_pod_status_wide.stdout }}" + notes: + sfm: + ssh_note: "SSH to the SFM IP with admin credentials." + ui_navigation: "{{ sfm_ui_navigation }}" + remote_write_enable_value: "{{ sfm_remote_write_enable_value }}" + remote_write_message_version: "{{ sfm_remote_write_message_version }}" + remote_write_target_name: "{{ sfm_remote_write_target_name }}" + vminsert_write_url: >- + {{ _vm_scheme }}://vminsert-victoria-cluster.{{ victoria_namespace }}.svc.cluster.local:{{ vm_vminsert_port }}/insert/0/prometheus/api/v1/write + tls_server_cert_file_name: "{{ sfm_tls_server_cert_file_name }}" + tls_server_cert_file_path: "{{ victoria_cert_dir_remote }}/ca.crt" + hosts_entry: "echo {{ _vminsert_ip }} vminsert-victoria-cluster.{{ victoria_namespace }}.svc.cluster.local >> /etc/hosts" + hosts_entry_vmselect: "echo {{ _vmselect_ip }} vmselect-victoria-cluster.{{ victoria_namespace }}.svc.cluster.local >> /etc/hosts" + hosts_restart_note: "Repeat /etc/hosts update if the SFM pod restarts." + hosts_scope_note: "/etc/hosts update is required only inside the SFM Prometheus pod (not on the SFM server host)." + pod_shell_command_example: "kubectl exec -it sfm-prometheus-deployment-xxxxx-xx -n sfm-1 -- /bin/sh" + +- name: Build VictoriaLogs connection details + ansible.builtin.set_fact: + _vl_details: + available: "{{ _vl_available | bool }}" + endpoints: + vlinsert: + host: "{{ _vlinsert_ip | default('') }}" + port: "{{ vl_vlinsert_port }}" + write_endpoint: "http://{{ _vlinsert_ip }}:{{ vl_vlinsert_port }}/insert/jsonline" + vlselect: + host: "{{ _vlselect_ip | default('') }}" + port: "{{ vl_vlselect_port }}" + query_endpoint: "http://{{ _vlselect_ip | default('') }}:{{ vl_vlselect_port }}/select/logsql/query" + ui_url: "http://{{ _vlselect_ip | default('') }}:{{ vl_vlselect_port }}/select/vmui" + namespace: "{{ victoria_namespace }}" + when: _vl_available | bool + +- name: Build VLAgent connection details + ansible.builtin.set_fact: + _vlagent_details: + available: "{{ _vlagent_available | bool }}" + host: "{{ _vlagent_ip | default('') }}" + namespace: "{{ victoria_namespace }}" + syslog_endpoint: "{{ _vlagent_ip }}:{{ vl_vlagent_syslog_port }}" + syslog_port: "{{ vl_vlagent_syslog_port }}" + when: _vlagent_available | bool + +- name: Build PowerScale syslog commands + ansible.builtin.set_fact: + _powerscale_details: + syslog_target: "{{ _vlagent_ip }}:{{ vl_vlagent_syslog_port }}" + isi_audit_commands: + - >- + isi audit settings global modify --config-syslog-enabled=yes + --config-syslog-tls-enabled=0 + --config-syslog-servers={{ _vlagent_ip }}:{{ vl_vlagent_syslog_port }} + - >- + isi audit settings global modify --protocol-syslog-enabled=yes + --protocol-syslog-tls-enabled=0 + --protocol-syslog-servers={{ _vlagent_ip }}:{{ vl_vlagent_syslog_port }} + - >- + isi audit settings global modify --system-syslog-enabled=yes + --system-syslog-tls-enabled=0 + --system-syslog-servers={{ _vlagent_ip }}:{{ vl_vlagent_syslog_port }} + - "isi audit settings modify --syslog-forwarding-enabled=yes" + - "isi audit settings global view" + when: _vlagent_available | bool + +# ============================================================================= +# Write output files +# ============================================================================= + +- name: Ensure output directory exists + ansible.builtin.file: + path: "{{ victoria_output_dir }}" + state: directory + mode: "0755" + delegate_to: localhost + connection: local + run_once: true + +- name: Assemble full connection details + ansible.builtin.set_fact: + _victoria_connect_details: + victoria_metrics: "{{ _vm_details }}" + victoria_logs: "{{ _vl_details | default({'available': false}) }}" + vlagent: "{{ _vlagent_details | default({'available': false}) }}" + powerscale: "{{ _powerscale_details | default({}) }}" + +- name: Write connection details to file + ansible.builtin.copy: + content: "{{ _victoria_connect_details | to_nice_yaml }}" + dest: "{{ victoria_output_file }}" + mode: "0644" + delegate_to: localhost + connection: local + run_once: true + +- name: Display Victoria connection details + ansible.builtin.debug: + msg: >- + {{ + [ + '══════════════════════════════════════════════════════════════', + 'Victoria External Connection Details', + '══════════════════════════════════════════════════════════════', + '', + 'Connection details written to: ' ~ victoria_output_file, + '', + '[VICTORIA METRICS]', + ' vminsert (write): ' ~ _vm_scheme ~ '://' ~ _vminsert_ip ~ ':' ~ vm_vminsert_port ~ '/insert/0/prometheus/api/v1/write', + ' vmselect (query): ' ~ _vm_scheme ~ '://' ~ _vmselect_ip ~ ':' ~ vm_vmselect_port ~ '/select/0/prometheus/api/v1/query', + ' vmui: ' ~ _vm_scheme ~ '://' ~ _vmselect_ip ~ ':' ~ vm_vmselect_port ~ '/select/0/vmui', + '', + '[VICTORIA LOGS]' ~ (' (available)' if (_vl_available | bool) else ' (not deployed)'), + ' vlinsert (write): ' ~ + ('http://' ~ _vlinsert_ip ~ ':' ~ vl_vlinsert_port ~ '/insert/jsonline' if (_vl_available | bool) else 'N/A'), + ' vlselect (query): ' ~ + ('http://' ~ (_vlselect_ip | default('')) ~ ':' ~ vl_vlselect_port ~ '/select/logsql/query' if (_vl_available | bool) else 'N/A'), + '', + '[VLAGENT SYSLOG]' ~ (' (available)' if (_vlagent_available | bool) else ' (not deployed)'), + ' syslog target: ' ~ ((_vlagent_ip ~ ':' ~ vl_vlagent_syslog_port) if (_vlagent_available | bool) else 'N/A'), + '', + '[SFM INTEGRATION]', + ' 1. SSH to SFM host', + ' 2. Navigate to: ' ~ sfm_ui_navigation, + ' 3. Enable Prometheus Remote Write: ' ~ sfm_remote_write_enable_value, + ' 4. Set target URL (inside SFM pod):', + ' ' ~ _vm_scheme ~ '://vminsert-victoria-cluster.' ~ victoria_namespace ~ + '.svc.cluster.local:' ~ vm_vminsert_port ~ '/insert/0/prometheus/api/v1/write', + ' 5. Add /etc/hosts entry inside SFM Prometheus pod:', + ' echo ' ~ _vminsert_ip ~ ' vminsert-victoria-cluster.' ~ victoria_namespace ~ '.svc.cluster.local >> /etc/hosts', + '', + '[POWERSCALE SYSLOG]' ~ (' (VLAgent available)' if (_vlagent_available | bool) else ' (VLAgent not deployed)'), + ' Run the isi audit commands from the connection details file.', + '', + '══════════════════════════════════════════════════════════════' + ] + }} + delegate_to: localhost + connection: local + run_once: true diff --git a/src/telemetry/roles/external_victoria_connect/vars/main.yml b/src/telemetry/roles/external_victoria_connect/vars/main.yml new file mode 100644 index 0000000000..30776f461c --- /dev/null +++ b/src/telemetry/roles/external_victoria_connect/vars/main.yml @@ -0,0 +1,73 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +--- + +# Namespace for all Victoria components +victoria_namespace: "telemetry" + +# VMCluster service names (derived from VMCluster CR name "victoria-cluster") +vm_vminsert_service: "vminsert-victoria-cluster" +vm_vmselect_service: "vmselect-victoria-cluster" + +# VMCluster ports +vm_vminsert_port: 8480 +vm_vmselect_port: 8481 + +# VLCluster service names (derived from VLCluster CR name "victoria-logs-cluster") +vl_vlinsert_service: "vlinsert-victoria-logs-cluster" +vl_vlselect_service: "vlselect-victoria-logs-cluster" + +# VLCluster ports +vl_vlinsert_port: 9481 +vl_vlselect_port: 9471 + +# VLAgent service name and syslog port +vl_vlagent_service: "vlagent" +vl_vlagent_syslog_port: 514 + +# TLS secret names (used when TLS is enabled) +victoria_tls_secret: "victoria-tls-certs" +victoria_cert_dir_remote: "/opt/omnia/telemetry/certs/victoria" + +# Output paths +victoria_output_dir: >- + {{ output_project_dir | default('/opt/omnia/telemetry/output/project_default') }}/external_victoria +victoria_output_file: "{{ victoria_output_dir }}/external_victoria_connect_details.yml" + +# Error messages +victoria_err_kube_vip_unreachable: >- + kube_vip host is not reachable over SSH: {{ ansible_host | default(inventory_hostname) }}. + Ensure the Kubernetes control plane VIP is reachable from this host. +victoria_err_no_vm_pods: >- + No VictoriaMetrics pods found in namespace '{{ victoria_namespace }}'. + Ensure telemetry has been deployed with --tags deploy before running this utility. +victoria_err_vm_pods_not_running: >- + One or more VictoriaMetrics pods are not in Running state in namespace '{{ victoria_namespace }}'. + Check: kubectl get pods -n {{ victoria_namespace }} -l app.kubernetes.io/component=monitoring +victoria_err_vminsert_ip_missing: >- + Failed to fetch vminsert LoadBalancer external IP. Ensure service '{{ vm_vminsert_service }}' + exists in namespace '{{ victoria_namespace }}' and has an external IP assigned. +victoria_err_vmselect_ip_missing: >- + Failed to fetch vmselect LoadBalancer external IP. Ensure service '{{ vm_vmselect_service }}' + exists in namespace '{{ victoria_namespace }}' and has an external IP assigned. +victoria_err_vlagent_ip_missing: >- + Failed to fetch VLAgent LoadBalancer external IP. Ensure service '{{ vl_vlagent_service }}' + exists in namespace '{{ victoria_namespace }}' and has an external IP assigned. + +# SFM integration instructions +sfm_remote_write_enable_value: "ON" +sfm_remote_write_message_version: "v1" +sfm_remote_write_target_name: "victoria" +sfm_ui_navigation: "Observability -> Settings -> Prometheus Remote Write" +sfm_tls_server_cert_file_name: "ca.crt" diff --git a/src/telemetry/roles/telemetry_setup/tasks/main.yml b/src/telemetry/roles/telemetry_setup/tasks/main.yml index 55f45a90e7..2a0093c731 100644 --- a/src/telemetry/roles/telemetry_setup/tasks/main.yml +++ b/src/telemetry/roles/telemetry_setup/tasks/main.yml @@ -33,10 +33,6 @@ - name: Display usage information ansible.builtin.include_tasks: display_usage.yml -- name: Install Python dependencies for telemetry modules - ansible.builtin.include_tasks: install_python_dependencies.yml - when: inventory_hostname != 'localhost' - - name: Set telemetry setup guard fact ansible.builtin.set_fact: telemetry_setup_done: true diff --git a/src/telemetry/roles/telemetry_setup/vars/main.yml b/src/telemetry/roles/telemetry_setup/vars/main.yml index 377e0287cc..51318ce087 100644 --- a/src/telemetry/roles/telemetry_setup/vars/main.yml +++ b/src/telemetry/roles/telemetry_setup/vars/main.yml @@ -42,7 +42,7 @@ telemetry_usage_info_msg: - " cleanup_powerscale - PowerScale only" - " cleanup_ufm - UFM only" - " cleanup_vast - VAST only" - - " cleanup_sfm - SFM only" + - "" - "Examples:" - " ansible-playbook playbooks/telemetry.yml --tags deploy" diff --git a/src/telemetry/vars/cleanup.yml b/src/telemetry/vars/cleanup.yml index 352fc9ebce..dd6bddff11 100644 --- a/src/telemetry/vars/cleanup.yml +++ b/src/telemetry/vars/cleanup.yml @@ -27,7 +27,6 @@ valid_cleanup_components: - cleanup_powerscale - cleanup_ufm - cleanup_vast - - cleanup_sfm # Timeouts cleanup_kafka_wait_timeout: "300s" @@ -313,16 +312,6 @@ vast_resources: vast_pod_label: "app=vast-external" -# --------------------------------------------------------------------------- -# SFM cleanup resources -# --------------------------------------------------------------------------- -sfm_resources: - - { type: "deployment", name: "sfm-exporter" } - - { type: "service", name: "sfm-exporter" } - - { type: "configmap", name: "sfm-scrape-config" } - -sfm_pod_label: "app=sfm-telemetry" - # --------------------------------------------------------------------------- # Victoria Operator cleanup (shared between VictoriaMetrics and VictoriaLogs) # --------------------------------------------------------------------------- diff --git a/test/image_build_manager/README.md b/test/image_build_manager/README.md index f4204f8bb2..0d5bad402c 100644 --- a/test/image_build_manager/README.md +++ b/test/image_build_manager/README.md @@ -61,12 +61,12 @@ bash setup_env.sh # Baremetal (default) or active venv bash setup_env.sh --venv # Create .venv/ and install there bash setup_env.sh --venv --force # Recreate .venv/ from scratch -# Step 4 — Set SSH password (required for remote mode) -bash setup_env.sh --set-password # Interactive prompt (2× confirmation) -bash setup_env.sh --password 'pass' # Non-interactive +# Step 4 — Set SSH credentials (optional; for remote mode) +bash setup_env.sh --set-creds # Interactive prompt (2x confirmation) +bash setup_env.sh --creds 'pass' # Non-interactive # Step 4b — Set domain credentials (S3 + aarch64) -bash setup_env.sh --set-domain-creds # Interactive prompt for S3 access/secret + aarch64 pw +bash setup_env.sh --set-domain-creds # Interactive prompt for S3 access/secret + aarch64 # Step 5 — Activate environment (if using --venv mode) source .venv/bin/activate # For --venv mode @@ -94,42 +94,50 @@ cd ../.. ### Credential Management -All credentials are stored in `test_creds.yml` and auto-encrypted with Ansible Vault. -SSH credential flags require `oim_server_ip` to be set in `test_config.yml`. -Domain credential flags (`--set-domain-creds` / `--domain-creds`) do **not** require -`oim_server_ip` — they only write to the local `test_creds.yml` file. +Two separate credential files are managed by `setup_env.sh`: + +| File | Location | Purpose | +|------|----------|---------| +| `test_creds.yml` | Local (this directory) | SSH credentials for remote test execution | +| `.test_creds.key` | Local (this directory) | Vault key for `test_creds.yml` (auto-created) | +| `image_build_credentials.yml` | `$OMNIA_DATA_PATH/image_build_manager/input/$OMNIA_PROJECT_NAME/` | S3 + aarch64 domain credentials | +| `.image_build_credentials_key` | Same directory as above | Vault key for domain credentials | + +All files are auto-encrypted with Ansible Vault and gitignored. +`oim_server_ip` is **not** required for credential setup — credentials are saved locally +and used when tests run (local or remote). #### SSH Credentials (OIM server access) | Flag | Description | |------|-------------| -| `--set-password` | Interactive prompt (asks twice). If password exists, asks yes/no to update. | -| `--update-password` | Force-update existing SSH password (no confirmation prompt). | -| `--password PWD` | Non-interactive. Overwrites any existing SSH credentials. | +| `--set-creds` | Interactive prompt (asks twice). If exists, asks yes/no to update. | +| `--update-creds` | Force-update existing SSH credentials (no confirmation prompt). | +| `--creds PWD` | Non-interactive. Overwrites any existing SSH credentials. | #### Domain Credentials (S3 / aarch64) The image build playbook (`image_build_manager.yml`) requires access to S3/MinIO -for image storage and optionally to a remote aarch64 host. These credentials are -stored alongside the SSH password in `test_creds.yml` (vault-encrypted) and are -read by `collect_build_credentials` during the test deployment step. -These flags do **not** require `oim_server_ip` — they only write to the local file. +for image storage and optionally to a remote aarch64 host. These credentials are +stored in a separate file (`image_build_credentials.yml`) at +`$OMNIA_DATA_PATH/image_build_manager/input/$OMNIA_PROJECT_NAME/`. | Field | Required | Description | |-------|----------|-------------| | `s3_access_id` | Yes | MinIO / S3 access key ID | | `s3_secret_key` | Yes | MinIO / S3 secret key | -| `aarch64_ssh_password` | No | SSH password for the aarch64 build host. Leave empty for key-based auth or if no aarch64 build is needed. | +| `aarch64_ssh_password` | No | SSH auth for the aarch64 build host. Leave empty for key-based auth or if no aarch64 build is needed. | | Flag | Description | |------|-------------| -| `--set-domain-creds` | Interactive prompt for S3 access ID, secret, and aarch64 password. | +| `--set-domain-creds` | Interactive prompt for S3 access ID, secret, and aarch64. | +| `--update-domain-creds` | Force-update domain credentials (no "exists" check). | | `--domain-creds JSON` | Non-interactive. Pass a JSON string with `s3_access_id`, `s3_secret_key`, `aarch64_ssh_password`. | -> **Note**: `--set-password` and `--set-domain-creds` are independent — run each separately, or combine in one invocation: +> **Note**: `--set-creds` and `--set-domain-creds` are independent — run each separately: > ```bash -> bash setup_env.sh --set-password # sets oim_password (requires oim_server_ip) -> bash setup_env.sh --set-domain-creds # sets S3 + aarch64 creds (no oim_server_ip needed) +> bash setup_env.sh --set-creds # SSH creds (saved locally) +> bash setup_env.sh --set-domain-creds # S3 + aarch64 (saved to $OMNIA_DATA_PATH) > ``` > Existing fields not updated by a given flag are **preserved**. @@ -246,8 +254,8 @@ Available markers: `sanity`, `x86_64`, `aarch64`, `functional`, `deploy` | File | Purpose | Git Status | |------|---------|------------| | `test_config.yml` | Target server IP, sync settings, dataset, report options | Tracked | -| `test_creds.yml` | SSH password (auto-encrypted with Ansible Vault) | **Gitignored** | -| `.test_creds.key` | Vault encryption key (auto-generated) | **Gitignored** | +| `test_creds.yml` | SSH creds (created by `--set-creds`, auto-encrypted) | **Gitignored** | +| `.test_creds.key` | Vault key for `test_creds.yml` (auto-created) | **Gitignored** | | `test_run_config.yml` | Batch execution: scenario order, markers, suites | Tracked | ### Key Settings in `test_config.yml` @@ -256,9 +264,7 @@ Available markers: `sanity`, `x86_64`, `aarch64`, `functional`, `deploy` |---------|----------|---------|-------------| | `oim_server_ip` | No | `""` (local) | Target server IP. Leave empty for local mode. | | `clone_path` | Remote only | `/omnia` | Path on the **target server** where project code is synced. In local mode, the playbook path is resolved automatically from the source tree. | -| `venv_path` | No | `""` | Python venv path on target. If set, activated before `ansible-playbook`. Leave empty to use system-wide ansible. | | `dataset` | No | `""` | Empty = input from target's `$OMNIA_DATA_PATH/image_build_manager/input//`. Set to a generated dataset name for custom inputs. | -| `project_name` | No | `project_default` | Project name for input/output paths on target. | ### Execution Modes @@ -362,12 +368,12 @@ See [`fvt/README.md`](fvt/README.md) for the complete test case registry. ``` test/image_build_manager/ -├── setup_env.sh # Environment setup (--venv, --set-password, etc.) +├── setup_env.sh # Environment setup (--venv, --set-creds, etc.) ├── run_validation.sh # Shell entry point (delegates to _run.py) ├── _run.py # Python entry point (loads domain vars, creates runner) ├── conftest.py # Pytest hooks, fixtures, report generation ├── test_config.yml # Target server and sync settings -├── test_creds.yml # All credentials: SSH + S3 + aarch64 (Ansible Vault, gitignored) +├── test_creds.yml # SSH credentials (auto-encrypted, gitignored) ├── test_run_config.yml # Batch execution config ├── requirements.txt # Python dependencies │ diff --git a/test/image_build_manager/library/functions/_config_helpers.py b/test/image_build_manager/library/functions/_config_helpers.py index 11c6b41c6b..ded1b97167 100644 --- a/test/image_build_manager/library/functions/_config_helpers.py +++ b/test/image_build_manager/library/functions/_config_helpers.py @@ -20,6 +20,7 @@ - Functional group resolution """ +import os import time from typing import List @@ -27,7 +28,6 @@ from omnia_auto import resolve_domain_input_path -from .host_func import load_test_config from ..vars.common_vars import ( DOMAIN_NAME, ENV_OMNIA_DATA_PATH, @@ -72,20 +72,25 @@ def _retry_run(host, cmd_str, retries: int = 2, delay: float = 3.0): # ============================================================================= def _get_shared_path() -> str: - """Get shared_path from test_config or fall back to constant. + """Get shared_path from OMNIA_DATA_PATH env var. - The shared_path is derived from OMNIA_DATA_PATH env var on the target: - /image_build_manager - Falls back to the SHARED_PATH constant (/opt/omnia/image_build_manager). + Constructs ``/image_build_manager`` from the + local environment (sourced from /etc/omnia/omnia.env). + Falls back to the SHARED_PATH constant. """ - config = load_test_config() - return config.get("shared_path", SHARED_PATH) + data_path = os.environ.get(ENV_OMNIA_DATA_PATH, "") + if data_path: + return f"{data_path}/{DOMAIN_NAME}" + return SHARED_PATH def _get_project_name() -> str: - """Get project_name from test_config or default.""" - config = load_test_config() - return config["project_name"] + """Get project_name from OMNIA_PROJECT_NAME env var. + + Reads from the local environment (sourced from /etc/omnia/omnia.env). + Falls back to 'project_default' if not set. + """ + return os.environ.get(ENV_OMNIA_PROJECT_NAME, "project_default") def _get_remote_ibm_config_path(host) -> str: diff --git a/test/image_build_manager/library/functions/host_func.py b/test/image_build_manager/library/functions/host_func.py index ff9a5ad9d4..8892c25acc 100644 --- a/test/image_build_manager/library/functions/host_func.py +++ b/test/image_build_manager/library/functions/host_func.py @@ -79,7 +79,6 @@ ENV_OMNIA_PROJECT_NAME, IBM_CONFIG_FILE, CREDENTIALS_FILE_NAME, - CREDENTIALS_KEY_NAME, SRC_INPUT_DIR, SRC_REPO_OUTPUT_DIR, ) @@ -119,7 +118,7 @@ def sync_project_to_remote(_host) -> Dict[str, Any]: dest=config["clone_path"], ip=conn["ip"], user=conn["user"], - password=conn["password"], + auth_secret=conn["auth_secret"], ssh_opts=conn["ssh_opts"], ) @@ -168,7 +167,7 @@ def sync_image_build_input(host) -> Dict[str, Any]: return sync_files( mode=conn["mode"], src=local_input, dest=remote_input, ip=conn["ip"], user=conn["user"], - password=conn["password"], ssh_opts=conn["ssh_opts"], + auth_secret=conn["auth_secret"], ssh_opts=conn["ssh_opts"], ) @@ -214,7 +213,7 @@ def sync_repo_manager_output(host) -> Dict[str, Any]: return sync_files( mode=conn["mode"], src=local_output, dest=remote_output_dir, ip=conn["ip"], user=conn["user"], - password=conn["password"], ssh_opts=conn["ssh_opts"], + auth_secret=conn["auth_secret"], ssh_opts=conn["ssh_opts"], ) diff --git a/test/image_build_manager/library/functions/log_func.py b/test/image_build_manager/library/functions/log_func.py index f36805ec0c..4e98b3d066 100644 --- a/test/image_build_manager/library/functions/log_func.py +++ b/test/image_build_manager/library/functions/log_func.py @@ -16,8 +16,7 @@ from typing import Dict, Any -from .host_func import load_test_config -from ._config_helpers import _get_shared_path +from ._config_helpers import _get_shared_path, _get_project_name from ..vars.common_vars import CMDS, BUILD_LOG_PATH @@ -38,19 +37,11 @@ def collect_build_logs(host, max_lines: int = 100) -> Dict[str, Any]: Returns: Dict with 'success', 'log_output', 'log_path'. """ - config = load_test_config() - project = config.get("project_name", "project_default") + project = _get_project_name() log_dir = BUILD_LOG_PATH.format( shared_path=_get_shared_path(), project=project, ) - # Find the most recent log file - find_cmd = host.run( - CMDS["cat_file"].format( - path=f"{log_dir}*.log" - ) - ) - # Try to get the latest .log file via ls -t ls_cmd = host.run( f"ls -t {log_dir}*.log 2>/dev/null | head -1" diff --git a/test/image_build_manager/library/vars/common_vars.py b/test/image_build_manager/library/vars/common_vars.py index 305715daa0..2a9a3081ad 100644 --- a/test/image_build_manager/library/vars/common_vars.py +++ b/test/image_build_manager/library/vars/common_vars.py @@ -185,7 +185,6 @@ # Required fields in test_config.yml REQUIRED_CONFIG_FIELDS = [ - "project_name", "clone_path", "report_path", "report_name", diff --git a/test/image_build_manager/setup_env.sh b/test/image_build_manager/setup_env.sh index 5345ac4ded..baa8a984f0 100755 --- a/test/image_build_manager/setup_env.sh +++ b/test/image_build_manager/setup_env.sh @@ -23,33 +23,31 @@ # Active venv — Auto-detected; installs into the currently active venv # New venv (--venv) — Creates .venv/ and installs there # -# CREDENTIAL HANDLING: -# SSH credentials (OIM server): -# --set-password — Prompt for SSH password (asks twice for confirmation). -# If password already exists, asks yes/no to update. -# --update-password — Force-update existing SSH password (prompt twice). -# --password — Set SSH password directly via flag (non-interactive). +# TWO CREDENTIAL FILES: +# 1. test_creds.yml — SSH password for OIM server access (local). +# 2. image_build_credentials.yml — Domain credentials (S3, aarch64). +# Created at $OMNIA_DATA_PATH/image_build_manager/input/$OMNIA_PROJECT_NAME/ +# and encrypted with ansible-vault. # -# Domain credentials (S3 / aarch64): -# --set-domain-creds — Interactive prompt for S3 access ID, S3 secret key, -# and (optional) aarch64 SSH password. -# If creds already exist, asks yes/no to update. -# --domain-creds — Set all domain credentials non-interactively. -# Pass as JSON: '{"s3_access_id":"x","s3_secret_key":"y"}' +# SSH CREDENTIALS: +# --set-creds Interactive prompt (2x confirmation). Asks to update if exists. +# --update-creds Force-update existing SSH password (2x prompt). +# --creds Non-interactive SSH password set. # -# All credentials are written to test_creds.yml and encrypted with ansible-vault. -# SSH credential flags require oim_server_ip to be set in test_config.yml. -# Domain credential flags (--set-domain-creds / --domain-creds) do NOT require -# oim_server_ip — they only write to the local test_creds.yml file. +# DOMAIN CREDENTIALS: +# --set-domain-creds Interactive prompt for S3 + aarch64 creds. +# --update-domain-creds Force-update domain credentials. +# --domain-creds Non-interactive. JSON: '{"s3_access_id":"x",...}' # # Usage: # bash setup_env.sh # Baremetal or active venv # bash setup_env.sh --venv # Create .venv/ and install there # bash setup_env.sh --venv --force # Recreate .venv/ from scratch -# bash setup_env.sh --set-password # Prompt for SSH password -# bash setup_env.sh --update-password # Update existing SSH password -# bash setup_env.sh --password "secret" # Set SSH password via flag +# bash setup_env.sh --set-creds # Prompt for SSH password +# bash setup_env.sh --update-creds # Update existing SSH password +# bash setup_env.sh --creds "secret" # Set SSH password via flag # bash setup_env.sh --set-domain-creds # Prompt for S3 + aarch64 creds +# bash setup_env.sh --domain-creds '{...}' # Non-interactive domain creds # bash setup_env.sh --debug # Verbose pip output # bash setup_env.sh --help # Show this help # ============================================================================= @@ -59,9 +57,19 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VENV_DIR="${SCRIPT_DIR}/.venv" REQUIREMENTS="${SCRIPT_DIR}/requirements.txt" + +# ── SSH credentials (local) ── CREDS_FILE="${SCRIPT_DIR}/test_creds.yml" CREDS_KEY="${SCRIPT_DIR}/.test_creds.key" +# ── Domain credentials (at env-var path) ── +DOMAIN_CREDS_FILENAME="image_build_credentials.yml" +DOMAIN_CREDS_KEY_FILENAME=".image_build_credentials_key" +DOMAIN_NAME="image_build_manager" + +# ── omnia_auto credential CLI ── +CRED_CLI="python3 -m omnia_auto" + # ───────────────────────────────────────────────────────────────────────────── # Colors & helpers # ───────────────────────────────────────────────────────────────────────────── @@ -77,6 +85,23 @@ ok() { echo -e " ${GREEN}[OK]${NC} $1"; } warn() { echo -e " ${YELLOW}[WARN]${NC} $1"; } fail() { echo -e " ${RED}[FAIL]${NC} $1"; exit 1; } +# ───────────────────────────────────────────────────────────────────────────── +# Resolve domain creds path from env vars +# ───────────────────────────────────────────────────────────────────────────── +_resolve_domain_creds_dir() { + local _data_path="${OMNIA_DATA_PATH:-/opt/omnia}" + local _project="${OMNIA_PROJECT_NAME:-project_default}" + echo "${_data_path}/${DOMAIN_NAME}/input/${_project}" +} + +_domain_creds_path() { + echo "$(_resolve_domain_creds_dir)/${DOMAIN_CREDS_FILENAME}" +} + +_domain_creds_key_path() { + echo "$(_resolve_domain_creds_dir)/${DOMAIN_CREDS_KEY_FILENAME}" +} + # ───────────────────────────────────────────────────────────────────────────── # Parse arguments # ───────────────────────────────────────────────────────────────────────────── @@ -84,124 +109,72 @@ USE_VENV=false FORCE=false DEBUG=false PIP_QUIET="--quiet" -SET_PASSWORD=false -UPDATE_PASSWORD=false -PASSWORD_VALUE="" +SET_CREDS=false +UPDATE_CREDS=false +CREDS_VALUE="" SET_DOMAIN_CREDS=false +UPDATE_DOMAIN_CREDS=false DOMAIN_CREDS_JSON="" TEST_CONFIG="${SCRIPT_DIR}/test_config.yml" +# shellcheck disable=SC2034 while [[ $# -gt 0 ]]; do case "$1" in - --venv) USE_VENV=true; shift ;; - --force) FORCE=true; shift ;; - --debug) DEBUG=true; PIP_QUIET=""; shift ;; - --set-password) SET_PASSWORD=true; shift ;; - --update-password) UPDATE_PASSWORD=true; shift ;; - --password) + --venv) USE_VENV=true; shift ;; + --force) FORCE=true; shift ;; + --debug) DEBUG=true; PIP_QUIET=""; shift ;; + --set-creds) SET_CREDS=true; shift ;; + --update-creds) UPDATE_CREDS=true; shift ;; + --creds) if [[ $# -lt 2 ]]; then - fail "--password requires a value. Usage: --password " + fail "--creds requires a value. Usage: --creds " fi - PASSWORD_VALUE="$2" - shift 2 - ;; - --set-domain-creds) SET_DOMAIN_CREDS=true; shift ;; + CREDS_VALUE="$2"; shift 2 ;; + --set-domain-creds) SET_DOMAIN_CREDS=true; shift ;; + --update-domain-creds) UPDATE_DOMAIN_CREDS=true; shift ;; --domain-creds) if [[ $# -lt 2 ]]; then - fail "--domain-creds requires a JSON value. Usage: --domain-creds '{\"s3_access_id\":\"x\",\"s3_secret_key\":\"y\"}'" + fail "--domain-creds requires JSON. Usage: --domain-creds '{\"s3_access_id\":\"x\"}'" fi - DOMAIN_CREDS_JSON="$2" - shift 2 - ;; + DOMAIN_CREDS_JSON="$2"; shift 2 ;; --help|-h) - echo "" - echo "Image Build Manager — Test Environment Setup" - echo "" - echo "Usage: bash setup_env.sh [OPTIONS]" - echo "" - echo "INSTALL MODES" - echo "─────────────────────────────────────────────────────────────────" - echo " (no flag) Baremetal mode (default)." - echo " Installs dependencies into the system Python" - echo " using 'pip install --user'. If a virtual env" - echo " is already activated (VIRTUAL_ENV is set), the" - echo " script auto-detects it and installs there instead." - echo "" - echo " --venv Create a new .venv/ virtual environment in the" - echo " current directory and install all dependencies" - echo " inside it. After setup, activate with:" - echo " source .venv/bin/activate" - echo "" - echo " --force Only used with --venv. Deletes the existing .venv/" - echo " directory and recreates it from scratch." - echo "" - echo "CREDENTIAL MANAGEMENT — SSH (OIM server access)" - echo "─────────────────────────────────────────────────────────────────" - echo " SSH credentials are stored in test_creds.yml and encrypted with" - echo " Ansible Vault automatically. oim_server_ip must be set in" - echo " test_config.yml for SSH credential flags to work." - echo "" - echo " --set-password Interactive SSH password setup. Prompts twice for" - echo " confirmation. If already set, asks yes/no to update." - echo "" - echo " --update-password" - echo " Force-update the existing SSH password. Prompts twice." - echo " Overwrites test_creds.yml and re-encrypts." - echo "" - echo " --password PWD Non-interactive SSH password set (overwrites existing)." - echo "" - echo "CREDENTIAL MANAGEMENT — Domain (S3 / aarch64)" - echo "─────────────────────────────────────────────────────────────────" - echo " These credentials are passed to the playbook via the domain" - echo " credentials input file. They are stored alongside the SSH" - echo " password in test_creds.yml (vault-encrypted)." - echo " NOTE: These flags do NOT require oim_server_ip — they only" - echo " write to the local test_creds.yml file." - echo "" - echo " --set-domain-creds" - echo " Interactive prompt for:" - echo " s3_access_id — MinIO / S3 access key" - echo " s3_secret_key — MinIO / S3 secret key" - echo " aarch64_ssh_password — aarch64 build host password" - echo " (leave blank if not used)" - echo " If already set, asks yes/no to update each field." - echo "" - echo " --domain-creds JSON" - echo " Non-interactive domain cred set via JSON string." - echo " Example:" - echo " --domain-creds '{\"s3_access_id\":\"key\",\"s3_secret_key\":\"sec\"}'" - echo "" - echo "OTHER OPTIONS" - echo "─────────────────────────────────────────────────────────────────" - echo " --debug Show verbose pip install output (no --quiet flag)." - echo "" - echo " --help, -h Show this help message and exit." - echo "" - echo "EXAMPLES" - echo "─────────────────────────────────────────────────────────────────" - echo " bash setup_env.sh # Baremetal install" - echo " bash setup_env.sh --venv # Create .venv/ and install" - echo " bash setup_env.sh --venv --force # Recreate .venv/ from scratch" - echo " bash setup_env.sh --set-password # Set SSH password (prompt)" - echo " bash setup_env.sh --update-password # Update existing SSH password" - echo " bash setup_env.sh --password 'mypass' # Set SSH password (inline)" - echo " bash setup_env.sh --set-domain-creds # Set S3 + aarch64 creds (prompt)" - echo " bash setup_env.sh --venv --set-password # Venv + SSH password prompt" - echo " bash setup_env.sh --debug # Verbose pip output" - echo "" - echo "FILES" - echo "─────────────────────────────────────────────────────────────────" - echo " test_config.yml Target server IP and sync settings" - echo " test_creds.yml All credentials: SSH + S3 + aarch64 (encrypted)" - echo " .test_creds.key Vault encryption key (auto-generated, gitignored)" - echo " .run_validation_rc Sourceable shell snippet (baremetal mode)" - echo " requirements.txt Python dependencies" - echo "" - exit 0 - ;; + cat <<'HELPEOF' + +Image Build Manager — Test Environment Setup + +Usage: bash setup_env.sh [OPTIONS] + +INSTALL MODES +───────────────────────────────────────────────────────────────── + (no flag) Baremetal mode (pip install --user). + --venv Create .venv/ and install there. + --force With --venv: recreate .venv/ from scratch. + +SSH CREDENTIALS (test_creds.yml) +───────────────────────────────────────────────────────────────── + --set-creds Interactive SSH password setup (2x confirmation). + --update-creds Force-update existing SSH password (2x prompt). + --creds PWD Non-interactive SSH password set. + +DOMAIN CREDENTIALS (image_build_credentials.yml) +───────────────────────────────────────────────────────────────── + Created at: $OMNIA_DATA_PATH/image_build_manager/input/$OMNIA_PROJECT_NAME/ + Fields: s3_access_id, s3_secret_key, aarch64_ssh_password. + + --set-domain-creds Interactive prompt for all domain fields. + --update-domain-creds Force-update domain creds (no "exists" check). + --domain-creds JSON Non-interactive. Example: + --domain-creds '{"s3_access_id":"key","s3_secret_key":"sec"}' + +OTHER OPTIONS +───────────────────────────────────────────────────────────────── + --debug Verbose pip output. + --help, -h Show this help. + +HELPEOF + exit 0 ;; *) - fail "Unknown option: $1 (use --help for usage)" - ;; + fail "Unknown option: $1 (use --help for usage)" ;; esac done @@ -240,7 +213,6 @@ INSTALL_MODE="baremetal" PIP_USER_FLAG="--user" if [ "$USE_VENV" = true ]; then - # User explicitly asked to create a venv INSTALL_MODE="venv" PIP_USER_FLAG="" @@ -262,13 +234,11 @@ if [ "$USE_VENV" = true ]; then ok "Activated .venv/" elif [ -n "${VIRTUAL_ENV:-}" ]; then - # User has their own venv already activated INSTALL_MODE="active-venv" PIP_USER_FLAG="" ok "Detected active virtual environment: ${VIRTUAL_ENV}" else - # Baremetal — install into system Python INSTALL_MODE="baremetal" PIP_USER_FLAG="--user" ok "Install mode: baremetal (system Python)" @@ -287,7 +257,6 @@ info "Installing dependencies from requirements.txt" pip install -r "$REQUIREMENTS" $PIP_QUIET $PIP_USER_FLAG 2>/dev/null || \ pip install -r "$REQUIREMENTS" $PIP_QUIET -# pytest-order for test ordering if ! pip show pytest-order &>/dev/null; then info "Installing pytest-order" pip install pytest-order $PIP_QUIET $PIP_USER_FLAG 2>/dev/null || \ @@ -297,182 +266,57 @@ fi ok "All dependencies installed" # ───────────────────────────────────────────────────────────────────────────── -# Step 4: Credential setup (--set-password / --update-password / --password) +# Step 4: Credential helpers (delegate to omnia_auto credential CLI) # ───────────────────────────────────────────────────────────────────────────── -# Check that oim_server_ip is configured in test_config.yml -_check_oim_server_ip() { +_show_oim_server_ip() { if [ ! -f "$TEST_CONFIG" ]; then - fail "test_config.yml not found at ${TEST_CONFIG}. Create it first." + warn "test_config.yml not found — set oim_server_ip for remote mode." + return fi local oim_ip - oim_ip=$(grep -E '^oim_server_ip:' "$TEST_CONFIG" 2>/dev/null | sed 's/^oim_server_ip:[[:space:]]*//; s/["'\''[:space:]]//g' || true) - if [ -z "$oim_ip" ]; then - fail "oim_server_ip is blank in test_config.yml. Set the target server IP first:\n vi ${TEST_CONFIG}" - fi - ok "Target server: ${oim_ip}" -} - -# _ensure_vault_key — creates .test_creds.key if it does not yet exist. -_ensure_vault_key() { - if [ ! -f "$CREDS_KEY" ]; then - info "Generating vault key: .test_creds.key" - python3 -c "import secrets; print(secrets.token_urlsafe(32)[:32])" > "$CREDS_KEY" - chmod 600 "$CREDS_KEY" - fi -} - -# _vault_encrypt — encrypt (or re-encrypt) CREDS_FILE. -_vault_encrypt() { - if command -v ansible-vault &>/dev/null; then - ansible-vault encrypt "$CREDS_FILE" --vault-password-file "$CREDS_KEY" 2>/dev/null - ok "Credentials encrypted: test_creds.yml" - else - warn "ansible-vault not found — credentials saved as plain text" - warn "Install ansible-core and re-run to encrypt" - fi -} - -# _read_existing_field — read a plain-text field value from CREDS_FILE. -# Works only before encryption. Returns empty string if field not found. -_read_existing_field() { - local _field="$1" - grep -E "^${_field}:" "$CREDS_FILE" 2>/dev/null \ - | sed "s/^${_field}:[[:space:]]*//; s/[\"']//g" || true -} - -# _decrypt_creds_temp — decrypt CREDS_FILE to a temp file. -# Sets DECRYPTED_CREDS_TMP and caller MUST clean it up. -_decrypt_creds_temp() { - DECRYPTED_CREDS_TMP=$(mktemp) - if command -v ansible-vault &>/dev/null && grep -q '^\$ANSIBLE_VAULT' "$CREDS_FILE" 2>/dev/null; then - ansible-vault decrypt --output "$DECRYPTED_CREDS_TMP" \ - --vault-password-file "$CREDS_KEY" "$CREDS_FILE" 2>/dev/null || true + oim_ip=$(grep -E '^oim_server_ip:' "$TEST_CONFIG" 2>/dev/null \ + | sed 's/^oim_server_ip:[[:space:]]*//; s/["'\''[:space:]]//g' || true) + if [ -n "$oim_ip" ]; then + ok "Target server: ${oim_ip}" else - cp "$CREDS_FILE" "$DECRYPTED_CREDS_TMP" + warn "oim_server_ip not set — credentials saved locally for later use." fi } -_create_and_encrypt_creds() { - # Args: $1 = oim_password - # $2 = s3_access_id (optional; keep existing if not provided) - # $3 = s3_secret_key (optional; keep existing if not provided) - # $4 = aarch64_ssh_password (optional; keep existing if not provided) - local _oim_pass="${1:-}" - local _s3_id="${2:-}" - local _s3_key="${3:-}" - local _aarch64_pass="${4:-}" - - # If file already exists, preserve existing values for fields not being updated - if [ -f "$CREDS_FILE" ]; then - _decrypt_creds_temp - [ -z "$_oim_pass" ] && _oim_pass=$(_read_existing_field "oim_password" < /dev/null; grep -E '^oim_password:' "$DECRYPTED_CREDS_TMP" | sed 's/^oim_password:[[:space:]]*//; s/[\"'\'']//g' || true) - [ -z "$_s3_id" ] && _s3_id=$(grep -E '^s3_access_id:' "$DECRYPTED_CREDS_TMP" | sed 's/^s3_access_id:[[:space:]]*//; s/[\"'\'']//g' || true) - [ -z "$_s3_key" ] && _s3_key=$(grep -E '^s3_secret_key:' "$DECRYPTED_CREDS_TMP" | sed 's/^s3_secret_key:[[:space:]]*//; s/[\"'\'']//g' || true) - [ -z "$_aarch64_pass" ] && _aarch64_pass=$(grep -E '^aarch64_ssh_password:' "$DECRYPTED_CREDS_TMP" | sed 's/^aarch64_ssh_password:[[:space:]]*//; s/[\"'\'']//g' || true) - rm -f "$DECRYPTED_CREDS_TMP" - fi - - # Write plain-text creds file (all fields) - cat > "$CREDS_FILE" << CREDS_EOF ---- -# Image Build Manager — test credentials -# Auto-encrypted with Ansible Vault. Do NOT commit this file. - -# SSH password for the remote OIM server (oim_server_ip in test_config.yml). -# Leave empty to use key-based authentication. -oim_password: "${_oim_pass}" - -# Image build credentials — synced to image_build_credentials.yml on the target. -# Required by the image_build_manager playbook for S3/MinIO access. -s3_access_id: "${_s3_id}" -s3_secret_key: "${_s3_key}" - -# aarch64 build host SSH password. -# Required only when aarch64_inventory_host_ip is set in image_build_config.yml. -# Leave empty for key-based auth or if no aarch64 host is configured. -aarch64_ssh_password: "${_aarch64_pass}" -CREDS_EOF - chmod 600 "$CREDS_FILE" - - _ensure_vault_key - _vault_encrypt +# Write SSH creds to test_creds.yml (local) +_write_ssh_creds() { + local _pass="$1" + $CRED_CLI write-fields \ + --creds-path "$CREDS_FILE" --key-path "$CREDS_KEY" \ + --fields "{\"oim_password\":\"${_pass}\"}" >/dev/null 2>&1 + ok "SSH credentials saved: test_creds.yml (encrypted)" } -# _prompt_build_creds — interactive prompt for S3 + aarch64 creds. -# Reads existing values from a decrypted copy; shows current value as default. -_prompt_build_creds() { - local _existing_id="" _existing_key="" _existing_aarch64="" - - if [ -f "$CREDS_FILE" ]; then - _decrypt_creds_temp - _existing_id=$(grep -E '^s3_access_id:' "$DECRYPTED_CREDS_TMP" | sed 's/^s3_access_id:[[:space:]]*//; s/[\"'\'']//g' || true) - _existing_key=$(grep -E '^s3_secret_key:' "$DECRYPTED_CREDS_TMP" | sed 's/^s3_secret_key:[[:space:]]*//; s/[\"'\'']//g' || true) - _existing_aarch64=$(grep -E '^aarch64_ssh_password:' "$DECRYPTED_CREDS_TMP" | sed 's/^aarch64_ssh_password:[[:space:]]*//; s/[\"'\'']//g' || true) - rm -f "$DECRYPTED_CREDS_TMP" - fi - - echo "" - echo -e " ${CYAN}Image Build Credentials — S3/MinIO + aarch64 build host${NC}" - echo "" - - # S3 Access ID - local _prompt_id=" S3 Access ID" - [ -n "$_existing_id" ] && _prompt_id="${_prompt_id} [current: ${_existing_id}]" - read -r -p "${_prompt_id}: " _new_id - local _s3_id="${_new_id:-$_existing_id}" - - # S3 Secret Key (masked) - echo -e " S3 Secret Key ${CYAN}(hidden input)${NC}:" - read -s -r -p " S3 Secret Key: " _new_key1; echo "" - local _s3_key="$_new_key1" - if [ -n "$_new_key1" ]; then - read -s -r -p " Confirm: " _new_key2; echo "" - if [ "$_new_key1" != "$_new_key2" ]; then - fail "S3 secret keys do not match. Re-run --set-domain-creds." - fi - else - _s3_key="$_existing_key" - warn "S3 secret key unchanged (press Enter to keep existing)." - fi - - # aarch64 SSH password (optional) - local _prompt_aarch64=" aarch64 SSH password (optional — press Enter to skip/keep)" - [ -n "$_existing_aarch64" ] && _prompt_aarch64="${_prompt_aarch64} [set]" - read -s -r -p "${_prompt_aarch64}: " _new_aarch64; echo "" - local _aarch64_pass="${_new_aarch64:-$_existing_aarch64}" - - echo "" - # Write back preserving oim_password - _create_and_encrypt_creds "" "$_s3_id" "$_s3_key" "$_aarch64_pass" +# Write domain creds to image_build_credentials.yml (at env-var path) +_write_domain_creds() { + local _json="$1" + local _path; _path=$(_domain_creds_path) + local _key; _key=$(_domain_creds_key_path) + local _dir; _dir=$(_resolve_domain_creds_dir) + + mkdir -p "$_dir" + $CRED_CLI write-fields \ + --creds-path "$_path" --key-path "$_key" \ + --fields "$_json" >/dev/null 2>&1 + ok "Domain credentials saved: $_path (encrypted)" } -# Prompt for credential with 2× confirmation -# Returns credential via stdout; all prompts/errors go to stderr -_prompt_credential() { - while true; do - read -s -r -p " Password: " _input1 - echo "" >&2 - read -s -r -p " Confirm: " _input2 - echo "" >&2 - - if [ -z "$_input1" ]; then - echo -e " ${RED}Password cannot be empty. Try again.${NC}" >&2 - echo "" >&2 - continue - fi - - if [ "$_input1" = "$_input2" ]; then - echo "$_input1" - return 0 - else - echo -e " ${RED}Passwords do not match. Try again.${NC}" >&2 - echo "" >&2 - fi - done +# Read a field from the domain creds file +_read_domain_field() { + local _field="$1" + local _path; _path=$(_domain_creds_path) + local _key; _key=$(_domain_creds_key_path) + $CRED_CLI read-field --creds-path "$_path" --key-path "$_key" \ + --field "$_field" 2>/dev/null || true } -# Ask yes/no with strict validation (loops until valid answer) +# Ask yes/no _ask_yes_no() { local prompt="$1" while true; do @@ -486,87 +330,125 @@ _ask_yes_no() { } # ───────────────────────────────────────────────────────────────────────────── -# SSH credential dispatch +# SSH credential dispatch (--set-creds / --update-creds / --creds) # ───────────────────────────────────────────────────────────────────────────── -if [ -n "$PASSWORD_VALUE" ]; then - # --password: non-interactive SSH password set - _check_oim_server_ip - info "Setting SSH password from --password flag" - _create_and_encrypt_creds "$PASSWORD_VALUE" - -elif [ "$UPDATE_PASSWORD" = true ]; then - # --update-password: force update, no "already set" check - _check_oim_server_ip +if [ -n "$CREDS_VALUE" ]; then + _show_oim_server_ip + info "Setting SSH password from --creds flag" + _write_ssh_creds "$CREDS_VALUE" + +elif [ "$UPDATE_CREDS" = true ]; then + _show_oim_server_ip if [ ! -f "$CREDS_FILE" ]; then - fail "No credentials file found. Use --set-password to create one first." + fail "No credentials file found. Use --set-creds to create one first." fi - echo "" - echo -e " ${CYAN}Update SSH password for the target OIM server.${NC}" - echo -e " ${CYAN}Existing S3 and aarch64 credentials are preserved.${NC}" - echo "" - _cred_input=$(_prompt_credential) - _create_and_encrypt_creds "$_cred_input" - ok "SSH password updated successfully" - -elif [ "$SET_PASSWORD" = true ]; then - # --set-password: check if already set, ask to update - _check_oim_server_ip + echo -e "\n ${CYAN}Update SSH password for the target OIM server.${NC}\n" + _cred_input=$($CRED_CLI prompt-and-confirm --message "SSH Password") + _write_ssh_creds "$_cred_input" +elif [ "$SET_CREDS" = true ]; then + _show_oim_server_ip if [ -f "$CREDS_FILE" ]; then warn "SSH password is already set (test_creds.yml exists)." if _ask_yes_no " Do you want to update the SSH password?"; then - echo "" - echo -e " ${CYAN}Enter new SSH password for the target OIM server.${NC}" - echo -e " ${CYAN}Existing S3 and aarch64 credentials are preserved.${NC}" - echo "" - _cred_input=$(_prompt_credential) - _create_and_encrypt_creds "$_cred_input" - ok "SSH password updated successfully" + echo -e "\n ${CYAN}Enter new SSH password for the target OIM server.${NC}\n" + _cred_input=$($CRED_CLI prompt-and-confirm --message "SSH Password") + _write_ssh_creds "$_cred_input" else - ok "SSH password update skipped. Existing credentials kept." + ok "SSH password update skipped." fi else - echo "" - echo -e " ${CYAN}Enter SSH password for the target OIM server.${NC}" - echo -e " ${CYAN}This will be saved to test_creds.yml (encrypted).${NC}" - echo "" - _cred_input=$(_prompt_credential) - _create_and_encrypt_creds "$_cred_input" + echo -e "\n ${CYAN}Enter SSH password for the target OIM server.${NC}\n" + _cred_input=$($CRED_CLI prompt-and-confirm --message "SSH Password") + _write_ssh_creds "$_cred_input" fi fi # ───────────────────────────────────────────────────────────────────────────── -# Domain credential dispatch (--set-domain-creds / --domain-creds) -# NOTE: These do NOT require oim_server_ip — they only write to local test_creds.yml. +# Domain credential dispatch (--set-domain-creds / --update-domain-creds / --domain-creds) # ───────────────────────────────────────────────────────────────────────────── if [ -n "$DOMAIN_CREDS_JSON" ]; then - # --domain-creds JSON: non-interactive (parse s3_access_id, s3_secret_key, aarch64_ssh_password) info "Setting domain credentials from --domain-creds flag" - _s3_id=$(echo "$DOMAIN_CREDS_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('s3_access_id',''))" 2>/dev/null || true) - _s3_key=$(echo "$DOMAIN_CREDS_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('s3_secret_key',''))" 2>/dev/null || true) - _aarch64=$(echo "$DOMAIN_CREDS_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('aarch64_ssh_password',''))" 2>/dev/null || true) - _create_and_encrypt_creds "" "$_s3_id" "$_s3_key" "$_aarch64" - ok "Domain credentials set" - -elif [ "$SET_DOMAIN_CREDS" = true ]; then - # --set-domain-creds: interactive prompt - _prompt_build_creds - ok "Domain credentials saved to test_creds.yml" + _write_domain_creds "$DOMAIN_CREDS_JSON" + +elif [ "$UPDATE_DOMAIN_CREDS" = true ] || [ "$SET_DOMAIN_CREDS" = true ]; then + _domain_path=$(_domain_creds_path) + + if [ "$SET_DOMAIN_CREDS" = true ] && [ -f "$_domain_path" ]; then + warn "Domain credentials already exist: $_domain_path" + if ! _ask_yes_no " Do you want to update domain credentials?"; then + ok "Domain credential update skipped." + SET_DOMAIN_CREDS=false + fi + fi + + if [ "$UPDATE_DOMAIN_CREDS" = true ] || [ "$SET_DOMAIN_CREDS" = true ]; then + echo "" + echo -e " ${CYAN}Image Build Credentials — S3/MinIO + aarch64 build host${NC}" + echo -e " ${CYAN}Press Enter to keep existing value (shown in brackets).${NC}" + echo "" + + # Read existing + _e_s3_id=$(_read_domain_field "s3_access_id") + _e_s3_key=$(_read_domain_field "s3_secret_key") + _e_aarch64=$(_read_domain_field "aarch64_ssh_password") + + # S3 Access ID + _p=" S3 Access ID"; [ -n "$_e_s3_id" ] && _p="${_p} [${_e_s3_id}]" + read -r -p "${_p}: " _n; _s3_id="${_n:-$_e_s3_id}" + + # S3 Secret Key (hidden, confirm) + echo -e " S3 Secret Key ${CYAN}(hidden input)${NC}:" + read -s -r -p " S3 Secret Key: " _k1; echo "" + if [ -n "$_k1" ]; then + read -s -r -p " Confirm: " _k2; echo "" + if [ "$_k1" != "$_k2" ]; then + fail "S3 secret keys do not match. Re-run --set-domain-creds." + fi + _s3_key="$_k1" + else + _s3_key="$_e_s3_key" + warn "S3 secret key unchanged." + fi + + # aarch64 SSH password (optional) + _p=" aarch64 SSH password (optional — Enter to skip/keep)" + [ -n "$_e_aarch64" ] && _p="${_p} [set]" + read -s -r -p "${_p}: " _n; echo "" + _aarch64="${_n:-$_e_aarch64}" + + # Build JSON and write + _json=$(python3 -c " +import json +d = {} +for k, v in [('s3_access_id','${_s3_id}'),('s3_secret_key','${_s3_key}'),('aarch64_ssh_password','${_aarch64}')]: + if v: + d[k] = v +print(json.dumps(d)) +") + echo "" + _write_domain_creds "$_json" + fi fi # ───────────────────────────────────────────────────────────────────────────── # No credential flags — status report # ───────────────────────────────────────────────────────────────────────────── -if [ -z "$PASSWORD_VALUE" ] && [ "$UPDATE_PASSWORD" = false ] && [ "$SET_PASSWORD" = false ] \ - && [ -z "$DOMAIN_CREDS_JSON" ] && [ "$SET_DOMAIN_CREDS" = false ]; then +if [ -z "$CREDS_VALUE" ] && [ "$UPDATE_CREDS" = false ] && [ "$SET_CREDS" = false ] \ + && [ -z "$DOMAIN_CREDS_JSON" ] && [ "$SET_DOMAIN_CREDS" = false ] \ + && [ "$UPDATE_DOMAIN_CREDS" = false ]; then if [ -f "$CREDS_FILE" ]; then - ok "Credentials file exists: test_creds.yml" - ok "SSH: re-run with --set-password or --update-password to change" - ok "Domain: re-run with --set-domain-creds to update S3/aarch64 creds" + ok "SSH credentials: test_creds.yml (encrypted)" + else + warn "No SSH credentials (test_creds.yml)" + warn " Set with: bash setup_env.sh --set-creds" + fi + _dc=$(_domain_creds_path) + if [ -f "$_dc" ]; then + ok "Domain credentials: $_dc (encrypted)" else - warn "No credentials file found (test_creds.yml)" - warn "SSH creds: bash setup_env.sh --set-password" - warn "Domain creds: bash setup_env.sh --set-domain-creds" + warn "No domain credentials: $_dc" + warn " Set with: bash setup_env.sh --set-domain-creds" fi fi @@ -604,29 +486,24 @@ case "$INSTALL_MODE" in esac echo "" -echo " Credentials (two separate types):" +echo " Credentials (two separate files):" echo "" echo " 1. SSH credentials (test_creds.yml) — for remote test execution:" if [ -f "$CREDS_FILE" ]; then echo " test_creds.yml exists (encrypted)" - echo " To update: bash setup_env.sh --set-password" - echo " Force update: bash setup_env.sh --update-password" + echo " To update: bash setup_env.sh --update-creds" else - echo " No SSH credentials set." - echo " For remote mode: bash setup_env.sh --set-password" + echo " Not set. Create with: bash setup_env.sh --set-creds" fi echo "" -echo " 2. Image build credentials (image_build_credentials.yml):" -echo " Managed by the playbook's collect_build_credentials role." -echo " For tests: generated via datasets/generator/ and synced to target." -echo " See: datasets/generator/README.md" - -echo "" -echo " Documentation:" -echo " docs/test_config.md # Configuration reference" -echo " docs/test_creds.md # SSH credentials setup" -echo " docs/test_run_config.md # Batch execution config" -echo " datasets/generator/README.md # Dataset + build credentials" +echo " 2. Image build domain credentials:" +_dc_summary=$(_domain_creds_path) +if [ -f "$_dc_summary" ]; then + echo " ${_dc_summary} (encrypted)" + echo " To update: bash setup_env.sh --update-domain-creds" +else + echo " Not set. Create with: bash setup_env.sh --set-domain-creds" +fi echo "" echo "=================================================================" echo "" diff --git a/test/image_build_manager/test_config.yml b/test/image_build_manager/test_config.yml index e08c562d40..25a0727d66 100644 --- a/test/image_build_manager/test_config.yml +++ b/test/image_build_manager/test_config.yml @@ -59,11 +59,6 @@ oim_ssh_port: 22 # resolved automatically from the source tree. clone_path: "/omnia" -# venv_path: Path to Python virtual environment on the target server. -# If set, the venv is activated before running ansible-playbook. -# Leave empty to use system-wide ansible. -venv_path: "" - # ============================================================================= # DATASET CONFIGURATION # ============================================================================= @@ -85,12 +80,6 @@ venv_path: "" # repo_manager_output/repo_status.yml (optional, for sync_output) dataset: "" -# Project name on the target server. -# Must match OMNIA_PROJECT_NAME env var on the target (default: project_default). -# Input syncs to: /image_build_manager/input// -# Output read from: /image_build_manager/output// -project_name: "project_default" - # ============================================================================= # SYNC — REPO MANAGER OUTPUT # ============================================================================= diff --git a/test/image_build_manager/test_creds.yml b/test/image_build_manager/test_creds.yml deleted file mode 100644 index 72bf2df11a..0000000000 --- a/test/image_build_manager/test_creds.yml +++ /dev/null @@ -1,113 +0,0 @@ -$ANSIBLE_VAULT;1.1;AES256 -61643831323664653832326161616563376137363030643665633832336362346431303339376632 -6335623938396263333162323231643461646264393161380a303738376136336536386539393036 -33356564323963623132366230376234383034323363353138633935303865366232646639326664 -6236636164376334650a363062653833393066336434323034353233303337626338326366303630 -35366334636463323363616133356565333861636365623262343035303462376163663366353566 -62643634643134376666636430613461333230656161313537666234636562386361383361616630 -34336636336633646235336463643063333239363635363134326637386537633537323239356336 -64643732653037353438373231313266306334613135376661316634326537346439313433643261 -63336538666666373438663532613466653039663537316337306438666631383037366466383933 -34373061623964656235383334376439313039663762376135313036383630346431613965303163 -34383064623565303732343966373632373531666633633239333932366138633664333338613265 -34383563616662663838633335393861663364326237333735656337636562623439626136313938 -38636265316637356335643061646361663634303562303461363631613437666532363434373962 -61323136626233376233616662346136613165396466323831383934643437633064336163623961 -38643435636164316439303566303463376465316333653365613133323439326664363038626635 -31366338376664323437636334613338316562366263643665363937626230353264323632643061 -37613161343136346436373535666637616230663866356664623635353333633837626639383764 -62646534343461303631316638623938316533623664353231313466643161633232393232363265 -63643435306563623763653730326430336134663762313436303336613361343763633763313431 -32356332626562663464383631303531363766383562303730613563663833376265353363656164 -63393262373638656666636132626336613838333237393430316233663439383131386634373033 -30363635353562623062343464643431306230393666366138346666303830373964306430616434 -30333931616330306266333165303061376133363966623336333333353137663630313465353837 -36396365656563643862616666636634316465666631623865356337396337356261666564623465 -61363961323064643938356638656262653766643164383538613135626636633231633633643365 -33656662643539616631396536663561356663643232343638356461623931303761666539323562 -31623138323731386564616335396564613039646330623332326336393732353761363333363337 -33633933326439376166356230646464313466303332656662613261616663343234386462646138 -38363339643965356436623731643838306364343034303836613062646563636164623066383239 -61616362333632303533343737643561643534653539303966653839306536303365313362353566 -30333034333438643538313636326261666633326162353235323131306561373934666234653733 -37353732353266343039323339393666643637643661623466386335643439383736643137363534 -66636536623332636532656330363336303833646161653739343432633765343030393163346339 -32376138343631636631656237663861663861353263376635636461373465303531363834373865 -37303633353834303639313737383066626632326362313665663635303137323030633133313332 -62363532626263313236366161333330383439653337656137353462363464646564333063356234 -35663138373537626335326163336230363039643035633163343166386463363862646639316330 -35633361353936386634393266663139323934343264666362633862653338643961643334343362 -64343862383065623630323661343634303730303030373236666534623434623331643234343966 -32363163333736396330376463343336356362636533363363306262626466333935343665666365 -64616334313465623333626333623865376263346465616332356131656563396238323762613964 -38346366613037353837343039326432346531643139613736383164653232326635376161353038 -36383739356161346138643330343936343636623939653162303364396464303330333035383437 -34656164383336383839643936623965653535366331303766323032663737653931326630616338 -31616437646130333731326264353336363335363637326163646466643965623437666638376533 -37626532346535643139373964306339343130613266393235663339343835613230366364323565 -34306531316263646130653038633237353364623838323963346330626338633865633663336466 -30346664343734396337316663316336396531303731663636333433386261643438626438333235 -32333862626433373666393339643437633062663163343330356335616466386134373662333234 -61316632636462623831316466643364653964306165633134323833636434626238323337393364 -34373364376461633736353263373931396432356165303236306162633066623863623961383230 -38346430346139313561373461373833613064383261623738306138653634656337333134623830 -34643365383030323063376665646335363339363132633061356236623830396235643962373233 -30306563356133303363643938343539313337386464656430353230656161393464386433386462 -38643162376533316539613832363832333461336532373836363135353533363162303536653539 -37336266386166373636323065346365303331623931666562366636333962336435386234396638 -35363731663734613866313261363338646365626465383837633765366565653162376161303165 -66663139373032386565653032663365633137666338353630626333376263313764323337306662 -32343565633664643933643430353661316664613135396438313837316664353565396563366138 -39633331343139333837343131653937316161396266656231613164393365303934313335646639 -65643632343366613236363565626163633463313931363462373433316632383732323165356438 -61383939623532373236313637363136626637643139303564343266636630333936363765383730 -37623634356631376466356133363365326331323364346536313466613261343364333534386638 -61663731383638643161323531663130663638333566633637663137643532306664383438323737 -39313630646363613465633934343935346232386462366461643863663962393261333162343930 -39353933633830393262363763663633313734646561383730366335376439326531343137393265 -64643731303061646235306663623163663162653130613131393039663761646636616465363038 -33363938316665326633326631356366316262613164383330366530656638303636376166303738 -34656536386163353064626362373537613930353439313962343332336262623264353133316665 -31333761626636316564643762636136373163363461356664623161353138663465663666663631 -38643230373032656133303163336661346439663261303863343634393366623731663232353064 -36393637613839356563393533396333663834313635363265373463643330343138656338303035 -33333636393062613536653032666263343837313333346164393765653064326165656139373765 -30363932663061376430643361396661393539386333353734656561633633333034373562666532 -38623531663664373638646136316237363862633938346439373733383738346634633362633932 -34373332333061373132356533623732623164663939326435316330343234393761383365316438 -32333232333539333332643632303566666235666534373864626365366164353834613835646239 -39363966646639646138656664333831313936656234343234316131336330616165323265393362 -62393766323037326631633766373064643963373735633430663465353833303862653933303465 -63303730363364373432623637313137313661376562366234326139346462626632633038656437 -35613363623865346631396164336562383062336433643238646266323362636133643738313537 -62303963656334343136313334316633613964353464636139353837306635363565366233356634 -34636162313236626265626335653130336336346463373534646464346432323134616139326638 -33303664306137366563656165663035356639333165343331356266663637623738633032393461 -30613065616539643331633363363636353465616339316461663363643865633736323330646431 -65633666376633303835623263633130323435633737393364343130663439613932386132303266 -31626435316233363338313635633636363466393934616161353263316235346564613665633264 -34313138366635646638303736306438383431656461336364643535346561373335306337326438 -38346539353232396136333864343265663133613930336366663361666234363436363633303239 -30383661613961343563333635366438356437383035613661366237636533613034633231363262 -66353035616363353832376238623166393930306430313439333530396233666462313839366437 -61383337376330346232653830616336646533626465616337653161636234616136303537366430 -63396366393234646433633565626165643134636464363030613336393763323431646430396465 -64643836363730366530303134393832636432383461363436303138366631306563383435363130 -64613561373465346534643034306430393662656636306164303238306431316633323832313665 -38366666643334373738633532336634393133323036383334353739306666613066343464653234 -63666639663539393966386333623233623033323864663536303161663130313161636332373138 -64656339376237396235313233636137393763656330646333613665623730346265386665643033 -62646665383462666435333430633765656133643934666135316266633364353337613339343034 -38313537643339373034636463323831356539303564653939333563373962323865633039303463 -38613339383838613433613063326262663166353134326261323062373038646637363639666434 -65343463663665343535356532613736376233633937363338373238343965363664373663653866 -30336430373338656336346265343837633665666434353761663630633632393639353861383965 -32356233626264623630313536623766636637303761353935366463303332383962373936333131 -31393163623132336530316164363934383738343061363965633166316636623439643236313261 -35393463633236393030356538613635666238376234636634396533373739643562313638623163 -34633932363236326663623936313338663363343464356435633038313837353532656464356565 -36343036373165393232656133303864323335623938303830333438366333386632373666666231 -38313062663365386239666136316531393538663164373763316233303139366463393663653730 -62636637393836613533326166613864386662386138663530353763633439383164653338356534 -39633133303263346239383631386238383939393132316332346633636134336431626239353937 -65633464363765653630 diff --git a/test/plugins/dist/omnia_auto-1.0.0-py3-none-any.whl b/test/plugins/dist/omnia_auto-1.0.0-py3-none-any.whl index 24f7934682..7e2b4ce143 100644 Binary files a/test/plugins/dist/omnia_auto-1.0.0-py3-none-any.whl and b/test/plugins/dist/omnia_auto-1.0.0-py3-none-any.whl differ diff --git a/test/plugins/omnia_auto/__init__.py b/test/plugins/omnia_auto/__init__.py index 039cb05958..47a3efcfbf 100644 --- a/test/plugins/omnia_auto/__init__.py +++ b/test/plugins/omnia_auto/__init__.py @@ -89,6 +89,25 @@ # --- Validation Runner --- from .functions.validation_runner import ValidationRunner +# --- Credential Management --- +from .functions.credential_func import ( + ensure_vault_key, + is_vault_encrypted, + vault_encrypt, + vault_decrypt_to_dict, + read_credential_field, + write_credential_fields, + prompt_credential, + prompt_and_confirm, +) + +# --- Credential Vars --- +from .vars.credential_vars import ( + get_data_path, + get_project_name, + get_domain_input_path, +) + __all__ = [ "__version__", # Config @@ -133,4 +152,17 @@ "sync_files", # Validation Runner "ValidationRunner", + # Credential Management + "ensure_vault_key", + "is_vault_encrypted", + "vault_encrypt", + "vault_decrypt_to_dict", + "read_credential_field", + "write_credential_fields", + "prompt_credential", + "prompt_and_confirm", + # Credential Vars + "get_data_path", + "get_project_name", + "get_domain_input_path", ] diff --git a/src/telemetry/playbooks/upgrade/sources/upgrade_sfm.yml b/test/plugins/omnia_auto/__main__.py similarity index 70% rename from src/telemetry/playbooks/upgrade/sources/upgrade_sfm.yml rename to test/plugins/omnia_auto/__main__.py index ba89d6af7f..54950dae06 100644 --- a/src/telemetry/playbooks/upgrade/sources/upgrade_sfm.yml +++ b/test/plugins/omnia_auto/__main__.py @@ -1,4 +1,3 @@ ---- # Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,13 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. ---- -- name: Upgrade SFM Telemetry - hosts: kube_vip_group - gather_facts: true - any_errors_fatal: true +"""CLI entry point for ``python -m omnia_auto``.""" - tasks: - - name: Placeholder task for SFM telemetry upgrade - ansible.builtin.debug: - msg: "SFM telemetry upgrade - to be implemented" +import sys + +from .functions.credential_func import main + +sys.exit(main()) diff --git a/test/plugins/omnia_auto/functions/__init__.py b/test/plugins/omnia_auto/functions/__init__.py index af1aa3c5a8..42a2827ab2 100644 --- a/test/plugins/omnia_auto/functions/__init__.py +++ b/test/plugins/omnia_auto/functions/__init__.py @@ -60,3 +60,14 @@ # --- Validation Runner --- from .validation_runner import ValidationRunner + +# --- Credential Management --- +from .credential_func import ( + ensure_vault_key, + is_vault_encrypted, + vault_encrypt, + vault_decrypt_to_dict, + read_credential_field, + write_credential_fields, + prompt_credential, +) diff --git a/test/plugins/omnia_auto/functions/credential_func.py b/test/plugins/omnia_auto/functions/credential_func.py new file mode 100644 index 0000000000..0969bea8b2 --- /dev/null +++ b/test/plugins/omnia_auto/functions/credential_func.py @@ -0,0 +1,555 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Credential management for omnia test automation. + +Provides vault key creation, credential encryption / decryption, +field reading / writing, and interactive prompting. + +All file paths are derived from environment variables set by +``omnia.env`` (``OMNIA_DATA_PATH``, ``OMNIA_PROJECT_NAME``). + +Usage from Python:: + + from omnia_auto.functions.credential_func import ( + ensure_vault_key, + write_credential_fields, + read_credential_field, + ) + +Usage from bash (CLI mode):: + + python -m omnia_auto.functions.credential_func ensure-key \\ + --key-path /opt/omnia/telemetry/input/project_default/.telemetry_credentials_key + + python -m omnia_auto.functions.credential_func write-fields \\ + --creds-path /opt/omnia/telemetry/input/project_default/telemetry_credentials.yml \\ + --key-path /opt/omnia/telemetry/input/project_default/.telemetry_credentials_key \\ + --fields '{"bmc_username": "admin", "bmc_password": ""}' +""" + +import argparse +import getpass +import json +import os +import secrets +import subprocess +import sys +from typing import Any, Dict + +import yaml + +from ..vars.credential_vars import ( + VAULT_FILE_MODE, + VAULT_HEADER, + VAULT_KEY_LENGTH, + VAULT_TIMEOUT, +) +from ..messages.credential_msgs import ( + CREDENTIAL_LOG_MSGS as LOG, + CREDENTIAL_ERROR_MSGS as ERR, +) + + +# ===================================================================== +# VAULT KEY MANAGEMENT +# ===================================================================== + +def ensure_vault_key(key_path: str) -> Dict[str, Any]: + """Create a vault key file if it does not already exist. + + Args: + key_path: Absolute path to the vault key file. + + Returns: + Dict with keys: created (bool), message (str). + """ + if os.path.exists(key_path): + return { + "created": False, + "message": LOG["vault_key_exists"].format(key_path=key_path), + } + token = secrets.token_urlsafe(VAULT_KEY_LENGTH)[:VAULT_KEY_LENGTH] + parent = os.path.dirname(key_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(key_path, "w", encoding="utf-8") as key_fh: + key_fh.write(token) + os.chmod(key_path, VAULT_FILE_MODE) + return { + "created": True, + "message": LOG["vault_key_created"].format(key_path=key_path), + } + + +# ===================================================================== +# VAULT ENCRYPTION / DECRYPTION +# ===================================================================== + +def is_vault_encrypted(creds_path: str) -> bool: + """Check whether a file is ansible-vault encrypted. + + Args: + creds_path: Path to the credentials file. + + Returns: + True if the first line starts with ``$ANSIBLE_VAULT``. + """ + if not os.path.exists(creds_path): + return False + with open(creds_path, "r", encoding="utf-8") as creds_fh: + first_line = creds_fh.readline().strip() + return first_line.startswith(VAULT_HEADER) + + +def vault_encrypt(creds_path: str, key_path: str) -> Dict[str, Any]: + """Encrypt a credentials file with ansible-vault. + + Args: + creds_path: Path to the plaintext credentials YAML. + key_path: Path to the vault key file. + + Returns: + Dict with keys: success (bool), message (str), error (str). + """ + if is_vault_encrypted(creds_path): + return { + "success": True, + "message": LOG["creds_already_encrypted"].format( + creds_path=creds_path, + ), + "error": "", + } + try: + subprocess.run( + [ + "ansible-vault", "encrypt", creds_path, + "--vault-password-file", key_path, + ], + capture_output=True, text=True, + timeout=VAULT_TIMEOUT, check=True, + ) + return { + "success": True, + "message": LOG["creds_encrypted"].format( + creds_path=creds_path, + ), + "error": "", + } + except FileNotFoundError: + return { + "success": False, + "message": "", + "error": ERR["vault_not_installed"], + } + except subprocess.CalledProcessError as exc: + return { + "success": False, + "message": "", + "error": ERR["encrypt_failed"].format( + creds_path=creds_path, + key_path=key_path, + error=exc.stderr.strip(), + ), + } + + +def vault_decrypt_to_dict( + creds_path: str, key_path: str, +) -> Dict[str, Any]: + """Decrypt an ansible-vault file and return contents as a dict. + + Args: + creds_path: Path to the encrypted credentials file. + key_path: Path to the vault key file. + + Returns: + Dict with keys: success (bool), data (dict), error (str). + """ + if not os.path.exists(creds_path): + return { + "success": False, + "data": {}, + "error": ERR["creds_not_found"].format( + creds_path=creds_path, + ), + } + if not os.path.exists(key_path): + return { + "success": False, + "data": {}, + "error": ERR["key_not_found"].format( + key_path=key_path, creds_path=creds_path, + ), + } + try: + result = subprocess.run( + [ + "ansible-vault", "view", creds_path, + "--vault-password-file", key_path, + ], + capture_output=True, text=True, + timeout=VAULT_TIMEOUT, check=True, + ) + data = yaml.safe_load(result.stdout) or {} + return {"success": True, "data": data, "error": ""} + except FileNotFoundError: + return { + "success": False, + "data": {}, + "error": ERR["vault_not_installed"], + } + except subprocess.CalledProcessError as exc: + return { + "success": False, + "data": {}, + "error": ERR["decrypt_failed"].format( + creds_path=creds_path, + key_path=key_path, + error=exc.stderr.strip(), + ), + } + + +# ===================================================================== +# FIELD OPERATIONS +# ===================================================================== + +def read_credential_field( + creds_path: str, + key_path: str, + field: str, +) -> Dict[str, Any]: + """Read a single field from a (possibly encrypted) credentials file. + + Args: + creds_path: Path to the credentials file. + key_path: Path to the vault key file. + field: YAML key to read. + + Returns: + Dict with keys: success (bool), value (str or None), error (str). + """ + if not os.path.exists(creds_path): + return { + "success": False, + "value": None, + "error": ERR["creds_not_found"].format( + creds_path=creds_path, + ), + } + + if is_vault_encrypted(creds_path): + result = vault_decrypt_to_dict(creds_path, key_path) + if not result["success"]: + return { + "success": False, + "value": None, + "error": result["error"], + } + data = result["data"] + else: + with open(creds_path, "r", encoding="utf-8") as creds_fh: + data = yaml.safe_load(creds_fh) or {} + + value = data.get(field) + if value is not None: + return {"success": True, "value": str(value), "error": ""} + return { + "success": False, + "value": None, + "error": LOG["field_not_found"].format( + field=field, creds_path=creds_path, + ), + } + + +def write_credential_fields( + creds_path: str, + key_path: str, + fields: Dict[str, str], + header_comment: str = "", +) -> Dict[str, Any]: + """Write / update fields in a credentials file and encrypt it. + + Existing fields not in *fields* are preserved. New fields are + merged. The file is encrypted after writing. + + Args: + creds_path: Path to the credentials file. + key_path: Path to the vault key file. + fields: Dict of field_name -> value to write. + header_comment: Optional comment block for the file header. + + Returns: + Dict with keys: success (bool), message (str), error (str). + """ + existing: Dict = {} + if os.path.exists(creds_path): + if is_vault_encrypted(creds_path): + result = vault_decrypt_to_dict(creds_path, key_path) + if not result["success"]: + return { + "success": False, + "message": "", + "error": result["error"], + } + existing = result["data"] + else: + with open(creds_path, "r", encoding="utf-8") as creds_fh: + existing = yaml.safe_load(creds_fh) or {} + + existing.update(fields) + + ensure_vault_key(key_path) + + parent = os.path.dirname(creds_path) + if parent: + os.makedirs(parent, exist_ok=True) + + with open(creds_path, "w", encoding="utf-8") as creds_fh: + if header_comment: + for line in header_comment.strip().splitlines(): + creds_fh.write(f"# {line}\n") + creds_fh.write("\n") + yaml.safe_dump( + existing, creds_fh, + default_flow_style=False, allow_unicode=True, + ) + os.chmod(creds_path, VAULT_FILE_MODE) + + enc_result = vault_encrypt(creds_path, key_path) + if not enc_result["success"]: + return enc_result + + return { + "success": True, + "message": LOG["creds_written"].format(creds_path=creds_path), + "error": "", + } + + +def prompt_credential( + message: str = "Enter secret", # noqa: S107 — prompt label, not a credential +) -> str: + """Prompt the user for a secret value (no echo). + + Args: + message: Prompt text. + + Returns: + The entered value. + """ + return getpass.getpass(prompt=f" {message}: ") + + +def prompt_and_confirm( + message: str = "Credential", +) -> str: + """Prompt for a secret twice and confirm both entries match. + + Loops until the user provides a non-empty value that matches + on both entries. All output goes to stderr so that the secret + is returned cleanly on stdout for shell capture. + + Args: + message: Prompt label (e.g. ``Credential``). + + Returns: + The confirmed secret value. + """ + while True: + first = getpass.getpass(prompt=f" {message}: ") + if not first: + print( + " ERROR: Value cannot be empty. Try again.\n", + file=sys.stderr, flush=True, + ) + continue + second = getpass.getpass(prompt=f" Confirm {message}: ") + if first == second: + return first + print( + " ERROR: Values do not match. Try again.\n", + file=sys.stderr, flush=True, + ) + + +# ===================================================================== +# CLI ENTRY POINT +# ===================================================================== + +_CLI_COMMANDS = frozenset({ + "ensure-key", "encrypt", "read-field", + "write-fields", "prompt", "prompt-and-confirm", + "is-encrypted", +}) + + +def _build_parser() -> argparse.ArgumentParser: + """Build the argparse parser for the credential CLI.""" + parser = argparse.ArgumentParser( + prog="python -m omnia_auto.functions.credential_func", + description="Credential management for omnia test automation.", + ) + sub = parser.add_subparsers(dest="command") + + # ensure-key + ek = sub.add_parser( + "ensure-key", help="Create vault key if missing.", + ) + ek.add_argument("--key-path", required=True) + + # encrypt + enc = sub.add_parser( + "encrypt", help="Encrypt a credentials file.", + ) + enc.add_argument("--creds-path", required=True) + enc.add_argument("--key-path", required=True) + + # read-field + rf = sub.add_parser( + "read-field", + help="Read one field from credentials.", + ) + rf.add_argument("--creds-path", required=True) + rf.add_argument("--key-path", required=True) + rf.add_argument("--field", required=True) + + # write-fields + wf = sub.add_parser( + "write-fields", + help="Write / merge fields and encrypt.", + ) + wf.add_argument("--creds-path", required=True) + wf.add_argument("--key-path", required=True) + wf.add_argument( + "--fields", required=True, + help='JSON string: {"field": "value", ...}', + ) + wf.add_argument("--header", default="") + + # prompt + pr = sub.add_parser( + "prompt", help="Prompt for a secret (no echo).", + ) + pr.add_argument( + "--message", default="Enter secret", # noqa: S107 + ) + + # prompt-and-confirm + pac = sub.add_parser( + "prompt-and-confirm", + help="Prompt for secret with confirmation (2x entry).", + ) + pac.add_argument( + "--message", default="Credential", + help="Prompt label (default: Credential).", + ) + + # is-encrypted + ie = sub.add_parser( + "is-encrypted", + help="Check if vault-encrypted (exit 0=yes, 1=no).", + ) + ie.add_argument("--creds-path", required=True) + + return parser + + +def main(argv=None) -> int: + """CLI entry point. + + Args: + argv: Command-line arguments (defaults to sys.argv[1:]). + + Returns: + Exit code (0 = success). + """ + parser = _build_parser() + args = parser.parse_args(argv) + + if not args.command: + parser.print_help() + return 1 + + if args.command == "ensure-key": + result = ensure_vault_key(args.key_path) + status = "CREATED" if result["created"] else "EXISTS" + print(status, flush=True) + + elif args.command == "encrypt": + ensure_vault_key(args.key_path) + result = vault_encrypt(args.creds_path, args.key_path) + if not result["success"]: + print( + f"ERROR: {result['error']}", + file=sys.stderr, flush=True, + ) + return 2 + print("ENCRYPTED", flush=True) + + elif args.command == "read-field": + result = read_credential_field( + args.creds_path, args.key_path, args.field, + ) + if result["success"]: + print(result["value"], flush=True) + else: + print( + f"ERROR: {result['error']}", + file=sys.stderr, flush=True, + ) + return 1 + + elif args.command == "write-fields": + fields = json.loads(args.fields) + result = write_credential_fields( + args.creds_path, args.key_path, + fields, header_comment=args.header, + ) + if result["success"]: + print("OK", flush=True) + else: + print( + f"ERROR: {result['error']}", + file=sys.stderr, flush=True, + ) + return 2 + + elif args.command == "prompt": + secret = prompt_credential(args.message) + print(secret, flush=True) + + elif args.command == "prompt-and-confirm": + secret = prompt_and_confirm(args.message) + print(secret, flush=True) + + elif args.command == "is-encrypted": + if is_vault_encrypted(args.creds_path): + print("YES", flush=True) + return 0 + print("NO", flush=True) + return 1 + + else: + parser.print_help() + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/plugins/omnia_auto/functions/host_func.py b/test/plugins/omnia_auto/functions/host_func.py index 4855ca2e32..251076f4b7 100644 --- a/test/plugins/omnia_auto/functions/host_func.py +++ b/test/plugins/omnia_auto/functions/host_func.py @@ -107,8 +107,8 @@ def load_test_config(config_path: Optional[str] = None) -> Dict[str, Any]: """ path = _resolve_config_path(config_path) if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - return yaml.safe_load(f) or {} + with open(path, "r", encoding="utf-8") as cfg_fh: + return yaml.safe_load(cfg_fh) or {} return {} @@ -126,7 +126,7 @@ def _is_vault_encrypted(file_path: str) -> bool: def _create_vault_key(key_path: str) -> None: - """Create a new vault key file with random 32-char password.""" + """Create a new vault key file with a random 32-char token.""" import secrets key = secrets.token_urlsafe(32)[:32] with open(key_path, "w", encoding="utf-8") as f: @@ -204,8 +204,8 @@ def load_test_credentials( f"Credentials encrypted but key not found: {key_path}" ) - with open(creds_path, "r", encoding="utf-8") as f: - creds = yaml.safe_load(f) or {} + with open(creds_path, "r", encoding="utf-8") as creds_fh: + creds = yaml.safe_load(creds_fh) or {} if not os.path.exists(key_path): _create_vault_key(key_path) @@ -293,7 +293,7 @@ def get_testinfra_host(): # Remote — SSH ssh_user = config["oim_ssh_user"] ssh_port = config.get("oim_ssh_port", 22) - ssh_password = credentials.get("oim_password", "") + ssh_auth = credentials.get("oim_password", "") inventory_dir = os.path.join( tempfile.gettempdir(), "omnia_auto_testinfra" @@ -308,13 +308,13 @@ def get_testinfra_host(): "-o LogLevel=ERROR", ) - with open(inventory_path, "w", encoding="utf-8") as f: - f.write("[all]\n") - f.write( + with open(inventory_path, "w", encoding="utf-8") as inv_fh: + inv_fh.write("[all]\n") + inv_fh.write( f"target ansible_host={oim_ip} " f"ansible_user={ssh_user} " f"ansible_port={ssh_port} " - f"ansible_ssh_pass={ssh_password} " + f"ansible_ssh_pass={ssh_auth} " f"ansible_connection=ssh " f"ansible_ssh_common_args='{ssh_args}'\n" ) @@ -345,7 +345,7 @@ def run_on_host(host, cmd: str): def connection_params() -> dict: - """Build mode / ip / user / password / ssh_opts from test config. + """Build mode / ip / user / auth_secret / ssh_opts from test config. Returns a dict ready to unpack into ``sync_files()`` or other functions that need SSH connection details:: @@ -354,7 +354,7 @@ def connection_params() -> dict: sync_files(mode=conn["mode"], ip=conn["ip"], ...) Returns: - Dict with keys: mode, ip, user, password, ssh_opts. + Dict with keys: mode, ip, user, auth_secret, ssh_opts. Raises: ValueError: If required config keys are missing for remote mode. @@ -380,11 +380,12 @@ def connection_params() -> dict: oim_ip = None oim_user = config.get("oim_ssh_user", "root") + oim_auth = creds.get("oim_password") or None return { "mode": "local" if local else "ssh", "ip": oim_ip, "user": oim_user, - "password": creds.get("oim_password") or None, + "auth_secret": oim_auth, "ssh_opts": get_setting( "ssh_opts", "-o StrictHostKeyChecking=no " diff --git a/test/plugins/omnia_auto/functions/runner_func.py b/test/plugins/omnia_auto/functions/runner_func.py index f0f6443461..badccc9aba 100644 --- a/test/plugins/omnia_auto/functions/runner_func.py +++ b/test/plugins/omnia_auto/functions/runner_func.py @@ -30,6 +30,7 @@ import os import re +import shlex import shutil import signal import subprocess @@ -64,7 +65,7 @@ def run_playbook( playbook: Optional[str] = None, - tag = None, + tag=None, extra_vars: Optional[Dict[str, str]] = None, verbosity: Optional[int] = None, timeout: Optional[int] = None, @@ -127,18 +128,19 @@ def run_playbook( logger_name = get_setting("runner_logger_name", "playbook_runner") # safe default log = TestLogger(logger_name) - password = credentials.get("oim_password", "") - if not local_mode and password and not shutil.which("sshpass"): + oim_auth = credentials.get("oim_password", "") + if not local_mode and oim_auth and not shutil.which("sshpass"): return _fail( playbook, 0.0, RUNNER_ASSERT_MSGS["sshpass_missing"], ) - venv_path = config.get("venv_path", "") - # venv_path is optional - if provided, venv is activated before ansible-playbook + # venv_path: derived from OMNIA_VENV_PATH in /etc/omnia/omnia.env + # on the target (or local) host — never from test_config.yml. + venv_env_var = "OMNIA_VENV_PATH" ansible_cmd = _build_ansible_cmd( - playbook, workdir, v, extra_vars, tag, limit, venv_path, + playbook, workdir, v, extra_vars, tag, limit, venv_env_var, ) if local_mode: @@ -172,28 +174,37 @@ def _build_ansible_cmd( extra_vars: Optional[Dict[str, str]], tag, limit: Optional[str], - venv_path: str = "", + venv_env_var: str = "OMNIA_VENV_PATH", ) -> str: - """Build the ``ansible-playbook`` command string.""" - v_flag = f" -{'v' * verbosity}" if verbosity > 0 else "" + """Build the ``ansible-playbook`` command string. - parts = [] - # Activate venv if specified - if venv_path: - parts.append(f"source {venv_path}/bin/activate &&") - parts.extend([ - f"cd {workdir} &&", - f"COLUMNS={get_setting('line_width', 160)} ansible-playbook {playbook}{v_flag}", # 160 safe default - ]) + The venv path is sourced from the ``OMNIA_VENV_PATH`` env var + defined in ``/etc/omnia/omnia.env`` on the target host. If the + env var is unset the command falls through without activation. + """ + v_flag = f" -{'v' * verbosity}" if verbosity > 0 else "" + env_file = "/etc/omnia/omnia.env" + + parts = [ + # Source omnia.env and activate venv from env var + f"set -a && . {env_file} && set +a &&", + f'if [ -n "${{{venv_env_var}}}" ]; then' + f' source "${{{venv_env_var}}}/bin/activate"; fi &&', + f"cd {shlex.quote(workdir)} &&", + f"COLUMNS={get_setting('line_width', 160)}" + f" ansible-playbook {shlex.quote(playbook)}{v_flag}", + ] if extra_vars: for key, val in extra_vars.items(): - parts.append(f'--extra-vars "{key}={val}"') + parts.append( + f"--extra-vars {shlex.quote(f'{key}={val}')}", + ) if tag: tag_str = ",".join(tag) if isinstance(tag, list) else tag - parts.append(f"--tags {tag_str}") + parts.append(f"--tags {shlex.quote(tag_str)}") if limit: - parts.append(f"--limit {limit}") + parts.append(f"--limit {shlex.quote(limit)}") return " ".join(parts) @@ -207,10 +218,13 @@ def _wrap_ssh( host = config["oim_server_ip"] user = config.get("oim_ssh_user", "root") port = str(config.get("oim_ssh_port", 22)) - password = credentials.get("oim_password", "") + oim_auth = credentials.get("oim_password", "") - if password: - parts = ["sshpass", f"-p '{password}'", "ssh", "-T"] + if oim_auth: + parts = [ + "sshpass", "-p", shlex.quote(oim_auth), + "ssh", "-T", + ] else: parts = ["ssh", "-T"] @@ -219,7 +233,11 @@ def _wrap_ssh( "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", ])) - parts.extend(["-p", port, f"{user}@{host}", f"'{cmd}'"]) + parts.extend([ + "-p", shlex.quote(port), + f"{shlex.quote(user)}@{shlex.quote(host)}", + shlex.quote(cmd), + ]) return " ".join(parts) diff --git a/test/plugins/omnia_auto/functions/sync_func.py b/test/plugins/omnia_auto/functions/sync_func.py index dc4718f5bc..9a07ef4673 100644 --- a/test/plugins/omnia_auto/functions/sync_func.py +++ b/test/plugins/omnia_auto/functions/sync_func.py @@ -28,18 +28,100 @@ # SSH clone clone_repo(mode="ssh", url="https://…", dest="/root/repo", ip=conn["ip"], user=conn["user"], - password=conn["password"], + auth_secret=conn["auth_secret"], ssh_opts=conn["ssh_opts"]) # Sync a directory over SSH sync_files(mode="ssh", src="/local/dir", dest="/remote/dir", ip=conn["ip"], user=conn["user"], + auth_secret=conn["auth_secret"], ssh_opts=conn["ssh_opts"]) """ import os +import shlex import subprocess -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional + + +# ===================================================================== +# INTERNAL HELPERS — List-based command builders (Checkmarx-safe) +# ===================================================================== + +def _parse_ssh_opts(ssh_opts: str) -> List[str]: + """Parse SSH options string into a list of arguments.""" + return shlex.split(ssh_opts) + + +def _build_ssh_cmd_list( + ip: str, + user: str, + auth_secret: Optional[str], + ssh_opts: str, + remote_cmd: str, +) -> List[str]: + """Build SSH command as a list (Checkmarx-safe, no shell mode). + + Args: + ip: Target host IP. + user: SSH user. + auth_secret: SSH auth secret (sshpass is used when set). + ssh_opts: SSH options string. + remote_cmd: Command to execute on the remote host. + + Returns: + Command as a list of strings. + """ + opts = _parse_ssh_opts(ssh_opts) + target = f"{user}@{ip}" + + if auth_secret: + return ["sshpass", "-p", auth_secret, "ssh"] + opts + [target, remote_cmd] + return ["ssh"] + opts + [target, remote_cmd] + + +def _build_scp_cmd_list( + ip: str, + user: str, + auth_secret: Optional[str], + ssh_opts: str, + src: str, + dest: str, +) -> List[str]: + """Build SCP command as a list (Checkmarx-safe, no shell mode). + + Args: + ip: Target host IP. + user: SSH user. + auth_secret: SSH auth secret (sshpass is used when set). + ssh_opts: SSH options string. + src: Local source path. + dest: Remote destination path. + + Returns: + Command as a list of strings. + """ + opts = _parse_ssh_opts(ssh_opts) + target = f"{user}@{ip}:{dest}" + + if auth_secret: + return ["sshpass", "-p", auth_secret, "scp"] + opts + [src, target] + return ["scp"] + opts + [src, target] + + +def _build_rsync_ssh_e(auth_secret: Optional[str], ssh_opts: str) -> str: + """Build the ``-e`` argument for rsync over SSH. + + Args: + auth_secret: SSH auth secret (sshpass is used when set). + ssh_opts: SSH options string. + + Returns: + SSH command string for rsync ``-e``. + """ + if auth_secret: + return f"sshpass -p {shlex.quote(auth_secret)} ssh {ssh_opts}" + return f"ssh {ssh_opts}" # ===================================================================== @@ -53,7 +135,7 @@ def clone_repo( *, ip: Optional[str] = None, user: str = "root", - password: Optional[str] = None, + auth_secret: Optional[str] = None, ssh_opts: str = "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR", force: bool = False, timeout: int = 300, @@ -66,7 +148,7 @@ def clone_repo( dest: Destination path for the repo. ip: Target host IP (required when ``mode="ssh"``). user: SSH user (default ``root``). - password: SSH password. When set, ``sshpass`` is used. + auth_secret: SSH auth secret. When set, ``sshpass`` is used. ssh_opts: SSH options string. force: Remove existing repo and re-clone. timeout: Subprocess timeout in seconds. @@ -74,7 +156,7 @@ def clone_repo( Returns: Dict with ``success``, ``details``, ``error``. """ - result = {"success": False, "details": "", "error": ""} + result: Dict[str, Any] = {"success": False, "details": "", "error": ""} if mode not in ("local", "ssh"): result["error"] = f"Invalid mode '{mode}': must be 'local' or 'ssh'" @@ -89,41 +171,51 @@ def clone_repo( result["error"] = "'dest' is required" return result - # --- helpers --------------------------------------------------- - def _run(cmd: str) -> subprocess.CompletedProcess: - if mode == "local": - return subprocess.run( - cmd, shell=True, capture_output=True, text=True, # nosec B602 - timeout=timeout, check=False, - ) - ssh_cmd = _build_ssh_cmd(ip, user, password, ssh_opts, cmd) + def _run_cmd(cmd_list: List[str]) -> subprocess.CompletedProcess: + """Run a command list with timeout.""" return subprocess.run( - ssh_cmd, shell=True, capture_output=True, text=True, # nosec B602 - timeout=timeout, check=False, + cmd_list, + capture_output=True, + text=True, + timeout=timeout, + check=False, ) + def _run_local(cmd: str) -> subprocess.CompletedProcess: + """Run a local shell command via bash -c (list args).""" + return _run_cmd(["bash", "-c", cmd]) + + def _run_ssh(remote_cmd: str) -> subprocess.CompletedProcess: + """Run a remote command via SSH (list args).""" + cmd_list = _build_ssh_cmd_list(ip, user, auth_secret, ssh_opts, remote_cmd) + return _run_cmd(cmd_list) + + def _run(cmd: str) -> subprocess.CompletedProcess: + """Run command locally or via SSH based on mode.""" + if mode == "local": + return _run_local(cmd) + return _run_ssh(cmd) + try: - # --- check existing repo ---------------------------------- - chk = _run(f"test -d {dest}/.git && echo YES || echo NO") + # Check existing repo + chk = _run(f"test -d {shlex.quote(dest)}/.git && echo YES || echo NO") repo_exists = chk.returncode == 0 and "YES" in chk.stdout if repo_exists and force: - rm = _run(f"rm -rf {dest}") + rm = _run(f"rm -rf {shlex.quote(dest)}") if rm.returncode != 0: result["error"] = f"Failed to remove {dest}: {rm.stderr}" return result repo_exists = False if not repo_exists: - cl = _run(f"git clone {url} {dest} 2>&1") + cl = _run(f"git clone {shlex.quote(url)} {shlex.quote(dest)}") if cl.returncode != 0: - result["error"] = ( - f"git clone failed: {cl.stdout}{cl.stderr}" - ) + result["error"] = f"git clone failed: {cl.stdout}{cl.stderr}" return result result["details"] = f"Cloned {url} -> {dest}" else: - _run(f"cd {dest} && git pull 2>&1") + _run(f"cd {shlex.quote(dest)} && git pull") result["details"] = f"Repo exists at {dest}, pulled latest" result["success"] = True @@ -147,7 +239,7 @@ def sync_files( *, ip: Optional[str] = None, user: str = "root", - password: Optional[str] = None, + auth_secret: Optional[str] = None, ssh_opts: str = "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR", timeout: int = 120, mkdir: bool = True, @@ -163,7 +255,7 @@ def sync_files( dest: Destination path. ip: Target host IP (required when ``mode="ssh"``). user: SSH user (default ``root``). - password: SSH password. When set, ``sshpass`` is used. + auth_secret: SSH auth secret. When set, ``sshpass`` is used. ssh_opts: SSH options string. timeout: Subprocess timeout in seconds. mkdir: Create destination parent directory before sync. @@ -171,7 +263,7 @@ def sync_files( Returns: Dict with ``success``, ``details``, ``error``. """ - result = {"success": False, "details": "", "error": ""} + result: Dict[str, Any] = {"success": False, "details": "", "error": ""} if mode not in ("local", "ssh"): result["error"] = f"Invalid mode '{mode}': must be 'local' or 'ssh'" @@ -222,17 +314,18 @@ def sync_files( # --- SSH mode --------------------------------------------- if mkdir: dest_dir = dest if is_dir else os.path.dirname(dest) - mkdir_cmd = _build_ssh_cmd( - ip, user, password, ssh_opts, - f"mkdir -p {dest_dir}", + mkdir_cmd = _build_ssh_cmd_list( + ip, user, auth_secret, ssh_opts, + f"mkdir -p {shlex.quote(dest_dir)}", ) subprocess.run( - mkdir_cmd, shell=True, capture_output=True, # nosec B602 - text=True, timeout=30, check=False, + mkdir_cmd, + capture_output=True, text=True, + timeout=30, check=False, ) if is_dir: - ssh_e = _build_ssh_e(password, ssh_opts) + ssh_e = _build_rsync_ssh_e(auth_secret, ssh_opts) r = subprocess.run( [ "rsync", "-avz", "-e", ssh_e, @@ -242,20 +335,14 @@ def sync_files( timeout=timeout, check=False, ) else: - if password: - r = subprocess.run( - f"sshpass -p '{password}' scp {ssh_opts} " - f"{src} {user}@{ip}:{dest}", - shell=True, capture_output=True, text=True, # nosec B602 - timeout=timeout, check=False, - ) - else: - r = subprocess.run( - ["scp", *ssh_opts.split(), src, - f"{user}@{ip}:{dest}"], - capture_output=True, text=True, - timeout=timeout, check=False, - ) + scp_cmd = _build_scp_cmd_list( + ip, user, auth_secret, ssh_opts, src, dest, + ) + r = subprocess.run( + scp_cmd, + capture_output=True, text=True, + timeout=timeout, check=False, + ) if r.returncode != 0: result["error"] = f"sync failed: {r.stderr}" @@ -270,30 +357,3 @@ def sync_files( result["error"] = f"OS error during sync: {exc}" return result - - -# ===================================================================== -# INTERNAL HELPERS -# ===================================================================== - -def _build_ssh_cmd( - ip: str, - user: str, - password: Optional[str], - ssh_opts: str, - cmd: str, -) -> str: - """Build an SSH (or sshpass + SSH) command string.""" - if password: - return ( - f"sshpass -p '{password}' ssh {ssh_opts} " - f"{user}@{ip} '{cmd}'" - ) - return f"ssh {ssh_opts} {user}@{ip} '{cmd}'" - - -def _build_ssh_e(password: Optional[str], ssh_opts: str) -> str: - """Build the ``-e`` argument for rsync over SSH.""" - if password: - return f"sshpass -p '{password}' ssh {ssh_opts}" - return f"ssh {ssh_opts}" diff --git a/test/plugins/omnia_auto/functions/validation_runner.py b/test/plugins/omnia_auto/functions/validation_runner.py index ac3d9e86ab..8da85d6488 100644 --- a/test/plugins/omnia_auto/functions/validation_runner.py +++ b/test/plugins/omnia_auto/functions/validation_runner.py @@ -21,6 +21,7 @@ import json import os +import re import subprocess import sys import tempfile @@ -33,6 +34,33 @@ from .formatting_func import Colors, _render_summary from ..vars.validation_vars import COMMANDS +# Regex for safe identifiers from config YAML (no shell metacharacters) +_SAFE_IDENT_RE = re.compile(r"^[a-zA-Z0-9_\-./]+$") + + +def _validate_config_value(value: str, label: str) -> str: + """Validate that a config-derived value is a safe identifier. + + Rejects values containing shell metacharacters or whitespace to + prevent command injection when config values are passed as + subprocess arguments. + + Args: + value: The string to validate. + label: Descriptive label for error messages. + + Returns: + The validated value (unchanged). + + Raises: + ValueError: If the value contains unsafe characters. + """ + if value and not _SAFE_IDENT_RE.match(value): + raise ValueError( + f"Unsafe {label} value in config: {value!r}" + ) + return value + # ===================================================================== # OUTPUT HELPERS @@ -574,14 +602,16 @@ def _cmd_list(self, category: str) -> int: # CONFIG BATCH # ----------------------------------------------------------------- - def _cmd_config(self) -> int: # pylint: disable=too-many-locals,too-many-branches,too-many-statements + def _cmd_config( # pylint: disable=too-many-locals,too-many-branches,too-many-statements + self, + ) -> int: """Batch execution from test_run_config.yml.""" if not os.path.isfile(self.config_file): _err(f"Config not found: {self.config_file}") return 1 - with open(self.config_file, encoding="utf-8") as fh: - cfg = yaml.safe_load(fh) or {} + with open(self.config_file, encoding="utf-8") as cfg_stream: + cfg = yaml.safe_load(cfg_stream) or {} report_id = _timestamp() os.environ["REPORT_ID"] = report_id @@ -614,6 +644,7 @@ def _cmd_config(self) -> int: # pylint: disable=too-many-locals,too-many-branch for name, sc in fvt_cfg.items(): if not isinstance(sc, dict): continue + _validate_config_value(str(name), "scenario name") total += 1 if not sc.get("run", False): _skip(f"fvt/{name}") @@ -624,16 +655,31 @@ def _cmd_config(self) -> int: # pylint: disable=too-many-locals,too-many-branch sc, g_dataset, g_sync_in, g_sync_out, ) extra = self._build_config_extra(sc) + sc_command = sc.get("command", "test") + if sc_command not in COMMANDS: + _err( + f"Invalid command '{sc_command}'" + f" in config for {name}" + ) + failed += 1 + continue + run_script = os.path.join( + self.script_dir, "_run.py", + ) + if not os.path.isfile(run_script): + _err(f"Run script not found: {run_script}") + failed += 1 + continue cmd_args = [ - sys.executable, - os.path.join( - self.script_dir, "_run.py", - ), - self.cat_fvt, name, - sc.get("command", "test"), + sys.executable, run_script, + self.cat_fvt, + _validate_config_value(name, "name"), + sc_command, ] + extra - rc = subprocess.call(cmd_args, env=env) + rc = subprocess.call( # nosec B603 + cmd_args, env=env, + ) if rc == 0: _pass(f"fvt/{name}") passed += 1 @@ -651,15 +697,29 @@ def _cmd_config(self) -> int: # pylint: disable=too-many-locals,too-many-branch total += 1 if cat_cfg.get("run", False): extra = self._build_config_extra(cat_cfg) + cat_command = cat_cfg.get("command", "test") + if cat_command not in COMMANDS: + _err( + f"Invalid command '{cat_command}'" + f" in config for {cat_name}" + ) + failed += 1 + continue + run_script = os.path.join( + self.script_dir, "_run.py", + ) + if not os.path.isfile(run_script): + _err(f"Run script not found: {run_script}") + failed += 1 + continue cmd_args = [ - sys.executable, - os.path.join( - self.script_dir, "_run.py", - ), - cat_key, - cat_cfg.get("command", "test"), + sys.executable, run_script, + _validate_config_value(cat_key, "category"), + cat_command, ] + extra - rc = subprocess.call(cmd_args) + rc = subprocess.call( # nosec B603 + cmd_args, + ) if rc == 0: _pass(cat_name) passed += 1 @@ -699,7 +759,9 @@ def _build_config_env( ) -> dict: """Build env dict for a config scenario.""" env = os.environ.copy() - ds = g_dataset or sc.get("dataset", "") + ds = _validate_config_value( + g_dataset or str(sc.get("dataset", "")), "dataset", + ) si = ( str(g_sync_in).lower() if g_sync_in != "" else str(sc.get("sync_input", "")).lower() @@ -720,10 +782,14 @@ def _build_config_env( def _build_config_extra(sc: dict) -> List[str]: """Build extra CLI args from a config scenario.""" extra: List[str] = [] - marker = sc.get("marker", "") + marker = _validate_config_value( + str(sc.get("marker", "")), "marker", + ) if marker: extra.extend(["--marker", marker]) - suite = sc.get("suite", "") + suite = _validate_config_value( + str(sc.get("suite", "")), "suite", + ) if suite: extra.extend(["--suite", suite]) return extra @@ -887,8 +953,8 @@ def _print_combined_summary( try: with open( results_file, encoding="utf-8", - ) as fh: - results = json.load(fh) + ) as results_fh: + results = json.load(results_fh) except (json.JSONDecodeError, OSError): return if results: diff --git a/test/plugins/omnia_auto/messages/__init__.py b/test/plugins/omnia_auto/messages/__init__.py index 846a444b70..af65534068 100644 --- a/test/plugins/omnia_auto/messages/__init__.py +++ b/test/plugins/omnia_auto/messages/__init__.py @@ -18,3 +18,8 @@ RUNNER_LOG_MSGS, RUNNER_ASSERT_MSGS, ) + +from .credential_msgs import ( + CREDENTIAL_LOG_MSGS, + CREDENTIAL_ERROR_MSGS, +) diff --git a/test/plugins/omnia_auto/messages/credential_msgs.py b/test/plugins/omnia_auto/messages/credential_msgs.py new file mode 100644 index 0000000000..b388cde19c --- /dev/null +++ b/test/plugins/omnia_auto/messages/credential_msgs.py @@ -0,0 +1,79 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Log and error messages for credential management.""" + +from typing import Dict + +CREDENTIAL_LOG_MSGS: Dict[str, str] = { + "vault_key_created": "Vault key created: {key_path}", + "vault_key_exists": "Vault key already exists: {key_path}", + "creds_encrypted": "Credentials encrypted: {creds_path}", + "creds_already_encrypted": "Credentials already encrypted: {creds_path}", + "creds_decrypted": "Credentials decrypted successfully", + "creds_written": "Credentials written and encrypted: {creds_path}", + "field_read": "Read field '{field}' from {creds_path}", + "field_not_found": "Field '{field}' not found in {creds_path}", + "creds_file_created": "Credentials file created: {creds_path}", + "fields_merged": "Merged {count} field(s) into {creds_path}", +} + +CREDENTIAL_ERROR_MSGS: Dict[str, str] = { + "vault_not_installed": ( + "ansible-vault not found.\n" + "\n" + " HOW TO FIX:\n" + " 1. Install ansible-core: pip install ansible-core\n" + " 2. Or activate the omnia venv: source /opt/omnia/venv/bin/activate" + ), + "encrypt_failed": ( + "Failed to encrypt {creds_path}: {error}\n" + "\n" + " HOW TO FIX:\n" + " 1. Verify vault key exists: ls -la {key_path}\n" + " 2. Verify file permissions: ls -la {creds_path}\n" + " 3. Re-create vault key and re-encrypt" + ), + "decrypt_failed": ( + "Failed to decrypt {creds_path}: {error}\n" + "\n" + " HOW TO FIX:\n" + " 1. Verify vault key matches: {key_path}\n" + " 2. If key is lost, delete the creds file and re-create it\n" + " 3. Check: ansible-vault view {creds_path}" + " --vault-password-file {key_path}" + ), + "creds_not_found": ( + "Credentials file not found: {creds_path}\n" + "\n" + " HOW TO FIX:\n" + " 1. Run setup_env.sh --set-domain-creds to create it\n" + " 2. Or create manually and encrypt with ansible-vault" + ), + "key_not_found": ( + "Vault key not found: {key_path}\n" + " Credentials are encrypted but the key is missing.\n" + "\n" + " HOW TO FIX:\n" + " 1. If you have a backup of the key, restore it\n" + " 2. Otherwise delete {creds_path} and re-create credentials" + ), + "env_var_missing": ( + "Required environment variable '{var}' is not set.\n" + "\n" + " HOW TO FIX:\n" + " 1. Source the omnia env: source /opt/omnia/venv/bin/activate\n" + " 2. Or set manually: export {var}=" + ), +} diff --git a/test/plugins/omnia_auto/messages/runner_msgs.py b/test/plugins/omnia_auto/messages/runner_msgs.py index 5868f83e56..b1dd535741 100644 --- a/test/plugins/omnia_auto/messages/runner_msgs.py +++ b/test/plugins/omnia_auto/messages/runner_msgs.py @@ -56,7 +56,7 @@ " 3. SSH to target and check running ansible processes" ), "sshpass_missing": ( - "sshpass is not installed. Required for SSH password auth.\n" + "sshpass is not installed. Required for SSH credential auth.\n" "\n" " HOW TO FIX:\n" " Install: dnf install -y sshpass (RHEL)" diff --git a/test/plugins/omnia_auto/vars/__init__.py b/test/plugins/omnia_auto/vars/__init__.py index cfda3e3715..fe53b4c4a4 100644 --- a/test/plugins/omnia_auto/vars/__init__.py +++ b/test/plugins/omnia_auto/vars/__init__.py @@ -12,13 +12,28 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""omnia-auto — Variables""" +"""omnia-auto — Variables (public re-exports).""" -from .common_vars import ( +from .common_vars import ( # noqa: F401 configure, get_setting, init_module_root, get_module_root, ) -from .validation_vars import COMMANDS +from .validation_vars import COMMANDS # noqa: F401 + +from .credential_vars import ( # noqa: F401 + ENV_OMNIA_DATA_PATH, + ENV_OMNIA_PROJECT_NAME, + ENV_OMNIA_VENV_PATH, + DEFAULT_DATA_PATH, + DEFAULT_PROJECT_NAME, + VAULT_KEY_LENGTH, + VAULT_FILE_MODE, + VAULT_HEADER, + VAULT_TIMEOUT, + get_data_path, + get_project_name, + get_domain_input_path, +) diff --git a/test/plugins/omnia_auto/vars/credential_vars.py b/test/plugins/omnia_auto/vars/credential_vars.py new file mode 100644 index 0000000000..50b817df81 --- /dev/null +++ b/test/plugins/omnia_auto/vars/credential_vars.py @@ -0,0 +1,85 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Credential management constants for omnia-auto. + +Generic framework constants only — no domain-specific file names, +field definitions, or paths. Each domain's ``setup_env.sh`` defines +its own file names and field lists and passes them to the credential +CLI at runtime. + +Environment variables used: + OMNIA_DATA_PATH — Root data directory (default ``/opt/omnia``). + OMNIA_PROJECT_NAME — Active project (default ``project_default``). +""" + +import os + +# ============================================================================= +# ENV VAR NAMES (keys — never hardcode the *values*) +# ============================================================================= + +ENV_OMNIA_DATA_PATH = "OMNIA_DATA_PATH" +ENV_OMNIA_PROJECT_NAME = "OMNIA_PROJECT_NAME" +ENV_OMNIA_VENV_PATH = "OMNIA_VENV_PATH" + +# Defaults used when env vars are unset (match omnia.env defaults) +DEFAULT_DATA_PATH = "/opt/omnia" +DEFAULT_PROJECT_NAME = "project_default" + +# ============================================================================= +# VAULT SETTINGS +# ============================================================================= + +VAULT_KEY_LENGTH = 32 +VAULT_FILE_MODE = 0o600 +VAULT_HEADER = "$ANSIBLE_VAULT" +VAULT_TIMEOUT = 30 + +# ============================================================================= +# PATH BUILDERS +# ============================================================================= + + +def get_data_path() -> str: + """Return OMNIA_DATA_PATH from environment. + + Returns: + The value of ``OMNIA_DATA_PATH`` or the default. + """ + return os.environ.get(ENV_OMNIA_DATA_PATH, DEFAULT_DATA_PATH) + + +def get_project_name() -> str: + """Return OMNIA_PROJECT_NAME from environment. + + Returns: + The value of ``OMNIA_PROJECT_NAME`` or the default. + """ + return os.environ.get(ENV_OMNIA_PROJECT_NAME, DEFAULT_PROJECT_NAME) + + +def get_domain_input_path(domain: str) -> str: + """Build ``//input//``. + + Args: + domain: Domain name (e.g. ``telemetry``). + + Returns: + Absolute path to the domain's input directory. + """ + return os.path.join( + get_data_path(), domain, "input", get_project_name(), + ) diff --git a/test/telemetry/README.md b/test/telemetry/README.md index 5f56e11299..22ad29a1c0 100644 --- a/test/telemetry/README.md +++ b/test/telemetry/README.md @@ -5,12 +5,13 @@ Functional Verification Testing (FVT) for the `telemetry` Ansible domain. ## Quick Start ```bash -# 1. One-time setup (creates venv, installs deps) -source setup_env.sh +# 1. One-time setup (installs deps) +bash setup_env.sh # 2. Configure target server -# Edit test_config.yml: set oim_server_ip, project_name -# Edit test_creds.yml: set SSH credentials (auto-encrypted) +# Edit test_config.yml: set oim_server_ip +# Set SSH credentials: +bash setup_env.sh --set-creds # 3. Run tests ./run_validation.sh fvt_telemetry precheck verify @@ -103,12 +104,13 @@ Sinks: VictoriaMetrics, VictoriaLogs, Kafka (Strimzi) ``` test/telemetry/ -├── setup_env.sh # Environment setup (--venv, --set-password, etc.) +├── setup_env.sh # Environment setup (--venv, --set-creds, etc.) ├── run_validation.sh # Shell entry point (delegates to _run.py) ├── _run.py # Python entry point (loads domain vars, creates runner) ├── conftest.py # Pytest hooks, fixtures, report generation ├── test_config.yml # Non-sensitive settings (IPs, paths) -├── test_creds.yml # Credentials (auto-encrypted, gitignored) +├── test_creds.yml # SSH creds (created by --set-creds, auto-encrypted) +├── .test_creds.key # Vault key for test_creds.yml (auto-created) ├── test_run_config.yml # Batch execution: scenario order, markers, suites │ ├── library/ # Reusable automation library @@ -135,7 +137,8 @@ test/telemetry/ │ │ ├── test_ldms.py │ │ ├── test_ome.py │ │ ├── test_powerscale.py -│ │ └── test_ufm.py +│ │ ├── test_ufm.py +│ │ └── test_vast.py │ └── cleanup/ # Cleanup tag tests │ ├── test_playbook.py # Playbook --tags cleanup │ └── cleanup/ # Verify pods removed, topics removed @@ -154,7 +157,8 @@ test/telemetry/ | Sources: OME | 3 | sanity + functional | | Sources: PowerScale | 6 | sanity + functional | | Sources: UFM | 4 | sanity + functional | -| **Total** | **29** | | +| Sources: VAST | 5 | sanity + functional | +| **Total** | **34** | | ## Output Format diff --git a/test/telemetry/fvt/deploy/sources/test_vast.py b/test/telemetry/fvt/deploy/sources/test_vast.py new file mode 100644 index 0000000000..a4cd33dc58 --- /dev/null +++ b/test/telemetry/fvt/deploy/sources/test_vast.py @@ -0,0 +1,281 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Telemetry Deploy — VAST Source Verification Tests. + +VAST Architecture: + VAST itself is external (NOT deployed by Omnia). + Omnia creates a headless K8s service (vast-external) pointing to the + VAST appliance IP, and a VMServiceScrape CR that instructs vmagent + to scrape the VAST Prometheus exporter. + + Data pipeline (metrics): + VAST Prometheus API (HTTPS) -> vmagent(shared) -> VictoriaMetrics + Data pipeline (logs): + VAST syslog -> VLAgent -> VictoriaLogs + +Test cases: + TC_SR_060: Verify VAST external service exists with correct endpoint + TC_SR_061: Verify VAST VMServiceScrape CR exists + TC_SR_062: Verify VAST credentials K8s secret exists + TC_SR_063: Verify VAST storage metrics in VictoriaMetrics + TC_SR_064: Verify VAST logs in VictoriaLogs +""" + +from datetime import datetime + +import pytest + +from library.functions import TestLogger +from library.vars.test_case_vars import TEST_CASES as TC +from library.vars.common_vars import VAST_EXPECTED_METRICS +from library.messages.telemetry_msgs import ( + TEST_LOG_MSGS as LOG_MSGS, + TEST_ASSERT_MSGS as ASSERT_MSGS, +) +from library.functions.telemetry_func import ( + is_source_enabled, + is_logs_enabled, +) +from library.functions.vast_func import ( + verify_vast_external_service, + verify_vast_vmscrape, + verify_vast_credentials_secret, + verify_vast_metrics, + verify_vast_logs, + get_vast_endpoint_from_config, +) + + +def _skip_if_vast_disabled(host): + """Skip test if VAST source is not enabled.""" + if not is_source_enabled(host, "vast"): + pytest.skip("VAST source not enabled in config") + + +def _format_metric_lines(metric_details): + """Format metrics into lines with value and timestamp.""" + if not metric_details: + return " (no metrics found)" + + lines = [] + for m in metric_details: + ts = m.get("timestamp", 0) + try: + ts_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, OSError): + ts_str = str(ts) + lines.append( + f" \u2713 {m['metric']}: {m['value']} ({ts_str})" + ) + return "\n".join(lines) + + +# ========================================================================= +# TC_SR_060: Verify VAST external service exists with correct endpoint +# ========================================================================= + +@pytest.mark.source +@pytest.mark.sanity +@pytest.mark.order(80) +def test_vast_external_service(host): + """Verify VAST external service exists with correct endpoint.""" + _skip_if_vast_disabled(host) + tc = TC["vast_external_svc"] + tl = TestLogger(tc["title"], tc["id"]) + + tl.check("Verifying VAST external headless service") + result = verify_vast_external_service(host) + + detail = ( + f"endpoint={result.get('endpoint_ip', '')}:" + f"{result.get('endpoint_port', '')}, " + f"expected={result.get('expected_endpoint', '')}:" + f"{result.get('expected_port', '')}" + ) + + if result["success"]: + tl.passed( + LOG_MSGS["vast_svc_exists"].format( + service=result["service_name"], + endpoint=f"{result['endpoint_ip']}:{result['endpoint_port']}", + ), + detail, + ) + else: + tl.failed( + LOG_MSGS["vast_svc_missing"].format(service=result["service_name"]), + detail, + ) + + assert result["success"], ASSERT_MSGS["vast_svc_missing"].format( + service=result["service_name"], + ) + + +# ========================================================================= +# TC_SR_061: Verify VAST VMServiceScrape CR exists +# ========================================================================= + +@pytest.mark.source +@pytest.mark.sanity +@pytest.mark.order(81) +def test_vast_vmscrape(host): + """Verify VAST VMServiceScrape CR exists.""" + _skip_if_vast_disabled(host) + tc = TC["vast_vmscrape"] + tl = TestLogger(tc["title"], tc["id"]) + + tl.check("Checking VAST VMServiceScrape CR") + result = verify_vast_vmscrape(host) + + if result["success"]: + detail = ( + f"port={result.get('port', '')}, " + f"path={result.get('path', '')}, " + f"interval={result.get('scrape_interval', '')}" + ) + tl.passed( + LOG_MSGS["vast_vmscrape_exists"].format(name=result["name"]), + detail, + ) + else: + tl.failed( + LOG_MSGS["vast_vmscrape_missing"].format(name=result["name"]), + result.get("error", ""), + ) + + assert result["success"], ASSERT_MSGS["vast_vmscrape_missing"].format( + name=result["name"], + ) + + +# ========================================================================= +# TC_SR_062: Verify VAST credentials K8s secret exists +# ========================================================================= + +@pytest.mark.source +@pytest.mark.sanity +@pytest.mark.order(82) +def test_vast_credentials_secret(host): + """Verify VAST credentials K8s secret exists.""" + _skip_if_vast_disabled(host) + tc = TC["vast_credentials_secret"] + tl = TestLogger(tc["title"], tc["id"]) + + # Check if auth_mode is basic (secret required) + cfg_result = get_vast_endpoint_from_config(host) + if not cfg_result["success"]: + tl.skipped("VAST endpoint not configured") + pytest.skip("VAST endpoint not configured") + + tl.check("Checking VAST credentials secret") + result = verify_vast_credentials_secret(host) + + if result["success"]: + tl.passed( + LOG_MSGS["vast_secret_exists"].format(secret=result["secret_name"]), + f"keys: {', '.join(result.get('keys_found', []))}", + ) + else: + tl.failed( + LOG_MSGS["vast_secret_missing"].format(secret=result["secret_name"]), + result.get("error", ""), + ) + + assert result["success"], ASSERT_MSGS["vast_secret_missing"].format( + secret=result["secret_name"], + ) + + +# ========================================================================= +# TC_SR_063: Verify VAST storage metrics in VictoriaMetrics +# ========================================================================= + +@pytest.mark.source +@pytest.mark.functional +@pytest.mark.order(83) +def test_vast_metrics_in_vm(host): + """Verify VAST storage metrics in VictoriaMetrics.""" + _skip_if_vast_disabled(host) + tc = TC["vast_metrics_in_vm"] + tl = TestLogger(tc["title"], tc["id"]) + + tl.check("Querying VictoriaMetrics for VAST storage metrics") + result = verify_vast_metrics(host, VAST_EXPECTED_METRICS) + + metric_lines = _format_metric_lines(result.get("metric_details", [])) + + if result["success"]: + details_lines = [ + f"Found: {len(result['found'])}/{len(VAST_EXPECTED_METRICS)} metrics", + "", + metric_lines, + ] + tl.passed( + LOG_MSGS["vast_metrics_found"].format(count=len(result["found"])), + "\n".join(details_lines), + ) + else: + missing_str = ", ".join(result["missing"]) + details_lines = [ + f"Found: {len(result['found'])}/{len(VAST_EXPECTED_METRICS)} metrics", + ] + for m in result["missing"]: + details_lines.append(f" \u2717 {m}: MISSING") + if result.get("metric_details"): + details_lines.append("") + details_lines.append(metric_lines) + tl.failed( + LOG_MSGS["vast_metrics_missing"].format(missing=missing_str), + "\n".join(details_lines), + ) + + assert result["success"], ASSERT_MSGS["vast_metrics_missing"].format( + missing=", ".join(result["missing"]), + ) + + +# ========================================================================= +# TC_SR_064: Verify VAST logs in VictoriaLogs +# ========================================================================= + +@pytest.mark.source +@pytest.mark.functional +@pytest.mark.order(84) +def test_vast_logs_in_vl(host): + """Verify VAST logs in VictoriaLogs.""" + _skip_if_vast_disabled(host) + if not is_logs_enabled(host, "vast"): + pytest.skip("VAST logs not enabled in config") + + tc = TC["vast_logs_in_vl"] + tl = TestLogger(tc["title"], tc["id"]) + + tl.check("Querying VictoriaLogs for VAST syslog entries") + result = verify_vast_logs(host) + + if result["success"]: + tl.passed( + LOG_MSGS["vast_logs_found"].format(count=result["count"]), + f"Sample: {result['sample_log']}", + ) + else: + tl.failed( + LOG_MSGS["vast_logs_missing"], + "", + ) + + assert result["success"], ASSERT_MSGS["vast_logs_missing"] diff --git a/test/telemetry/library/functions/__init__.py b/test/telemetry/library/functions/__init__.py index d06f985c48..3de0aea47e 100644 --- a/test/telemetry/library/functions/__init__.py +++ b/test/telemetry/library/functions/__init__.py @@ -98,6 +98,16 @@ upload_ome_certs, ) +# --- VAST verification --- +from .vast_func import ( + verify_vast_external_service, + verify_vast_vmscrape, + verify_vast_credentials_secret, + verify_vast_metrics, + verify_vast_logs, + get_vast_endpoint_from_config, +) + # --- Validation --- from .validation_func import ( validate_test_config, @@ -174,6 +184,13 @@ def run_playbook(tag=None, **kwargs): "convert_certs_to_pfx", "verify_ome_kafka_user_cr", "upload_ome_certs", + # vast + "verify_vast_external_service", + "verify_vast_vmscrape", + "verify_vast_credentials_secret", + "verify_vast_metrics", + "verify_vast_logs", + "get_vast_endpoint_from_config", # validation "validate_test_config", "validate_all", diff --git a/test/telemetry/library/functions/host_func.py b/test/telemetry/library/functions/host_func.py index e39137c7b8..e965489e65 100644 --- a/test/telemetry/library/functions/host_func.py +++ b/test/telemetry/library/functions/host_func.py @@ -66,7 +66,7 @@ def sync_project_to_remote(_host) -> Dict[str, Any]: dest=config["clone_path"], ip=conn["ip"], user=conn["user"], - password=conn["password"], + auth_secret=conn["auth_secret"], ssh_opts=conn["ssh_opts"], ) @@ -104,5 +104,5 @@ def sync_telemetry_input(host) -> Dict[str, Any]: return sync_files( mode=conn["mode"], src=local_input, dest=remote_input, ip=conn["ip"], user=conn["user"], - password=conn["password"], ssh_opts=conn["ssh_opts"], + auth_secret=conn["auth_secret"], ssh_opts=conn["ssh_opts"], ) diff --git a/test/telemetry/library/functions/vast_func.py b/test/telemetry/library/functions/vast_func.py new file mode 100644 index 0000000000..d7419b2d5f --- /dev/null +++ b/test/telemetry/library/functions/vast_func.py @@ -0,0 +1,309 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +VAST — Module-Specific Verification Functions. + +Handles: + - VAST external headless service verification + - VAST VMServiceScrape CR verification + - VAST credentials K8s secret verification + - VAST storage metrics in VictoriaMetrics + - VAST logs in VictoriaLogs +""" + +import json + +from omnia_auto import read_yaml_key + +from ..vars.common_vars import ( + TELEMETRY_NAMESPACE, + VAST_SVC_NAME, + VAST_VMSCRAPE_NAME, + VAST_SECRET_NAME, + CFG_KEY_VAST_ENDPOINT, + CFG_KEY_VAST_PORT, + CMDS, +) +from .telemetry_func import ( + load_telemetry_config_from_target, + run_on_kube_vip, + query_vm_metric_names, + query_vm_instant, + get_vlselect_endpoint, +) + + +def verify_vast_external_service(host): + """Verify VAST external headless service exists and has correct endpoint. + + Args: + host: Testinfra host connection to the OIM. + + Returns: + dict with keys: success, service_name, endpoint_ip, endpoint_port, + expected_endpoint, expected_port. + """ + svc_cmd = ( + f"kubectl get svc {VAST_SVC_NAME} -n {TELEMETRY_NAMESPACE}" + " -o json 2>/dev/null" + ) + result = run_on_kube_vip(host, svc_cmd) + if result.rc != 0 or not result.stdout.strip(): + return { + "success": False, + "service_name": VAST_SVC_NAME, + "error": "Service not found", + } + + try: + svc = json.loads(result.stdout) + except json.JSONDecodeError: + return { + "success": False, + "service_name": VAST_SVC_NAME, + "error": "JSON parse error", + } + + svc_port = "" + for p in svc.get("spec", {}).get("ports", []): + svc_port = str(p.get("port", "")) + break + + # Get endpoints + ep_cmd = ( + f"kubectl get endpoints {VAST_SVC_NAME} -n {TELEMETRY_NAMESPACE}" + " -o json 2>/dev/null" + ) + ep_result = run_on_kube_vip(host, ep_cmd) + endpoint_ip = "" + endpoint_port = "" + if ep_result.rc == 0 and ep_result.stdout.strip(): + try: + ep_data = json.loads(ep_result.stdout) + for subset in ep_data.get("subsets", []): + for addr in subset.get("addresses", []): + endpoint_ip = addr.get("ip", "") + break + for port in subset.get("ports", []): + endpoint_port = str(port.get("port", "")) + break + except json.JSONDecodeError: + pass + + # Get expected from config + config = load_telemetry_config_from_target(host) + expected_endpoint = read_yaml_key(config, CFG_KEY_VAST_ENDPOINT, default="") + expected_port = str(read_yaml_key(config, CFG_KEY_VAST_PORT, default="443")) + + match = endpoint_ip == expected_endpoint + + return { + "success": match and bool(endpoint_ip), + "service_name": VAST_SVC_NAME, + "endpoint_ip": endpoint_ip, + "endpoint_port": endpoint_port, + "svc_port": svc_port, + "expected_endpoint": expected_endpoint, + "expected_port": expected_port, + } + + +def verify_vast_vmscrape(host): + """Verify VAST VMServiceScrape CR exists. + + Args: + host: Testinfra host connection to the OIM. + + Returns: + dict with keys: success, name, scrape_interval, port, path. + """ + cmd = ( + f"kubectl get vmservicescrape {VAST_VMSCRAPE_NAME} -n {TELEMETRY_NAMESPACE}" + " -o json 2>/dev/null" + ) + result = run_on_kube_vip(host, cmd) + if result.rc != 0 or not result.stdout.strip(): + return { + "success": False, + "name": VAST_VMSCRAPE_NAME, + "error": "Not found", + } + + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + return { + "success": False, + "name": VAST_VMSCRAPE_NAME, + "error": "JSON parse error", + } + + endpoints = data.get("spec", {}).get("endpoints", []) + interval = "" + port = "" + path = "" + if endpoints: + interval = endpoints[0].get("interval", "") + port = endpoints[0].get("port", "") + path = endpoints[0].get("path", "/api/prometheusmetrics/all") + + return { + "success": True, + "name": VAST_VMSCRAPE_NAME, + "scrape_interval": interval, + "port": port, + "path": path, + } + + +def verify_vast_credentials_secret(host): + """Verify VAST credentials K8s secret exists. + + Args: + host: Testinfra host connection to the OIM. + + Returns: + dict with keys: success, secret_name, keys_found. + """ + cmd = ( + f"kubectl get secret {VAST_SECRET_NAME} -n {TELEMETRY_NAMESPACE}" + " -o json 2>/dev/null" + ) + result = run_on_kube_vip(host, cmd) + if result.rc != 0 or not result.stdout.strip(): + return { + "success": False, + "secret_name": VAST_SECRET_NAME, + "error": "Not found", + } + + try: + data = json.loads(result.stdout) + keys_found = list(data.get("data", {}).keys()) + except json.JSONDecodeError: + return { + "success": False, + "secret_name": VAST_SECRET_NAME, + "error": "JSON parse", + } + + return { + "success": len(keys_found) > 0, + "secret_name": VAST_SECRET_NAME, + "keys_found": keys_found, + } + + +def verify_vast_metrics(host, expected_metrics): + """Verify VAST storage metrics exist in VictoriaMetrics. + + Args: + host: Testinfra host connection to the OIM. + expected_metrics: List of metric names to check. + + Returns: + dict with keys: success, found, missing, metric_details. + """ + all_names = query_vm_metric_names(host) + found = [m for m in expected_metrics if m in all_names] + missing = [m for m in expected_metrics if m not in all_names] + + metric_details = [] + for metric in found: + results = query_vm_instant(host, metric) + if results: + val = results[0].get("value", [None, "N/A"]) + timestamp = int(float(val[0])) if val[0] else 0 + value = val[1] if len(val) > 1 else "N/A" + metric_details.append({ + "metric": metric, + "value": value, + "timestamp": timestamp, + }) + + return { + "success": len(missing) == 0, + "found": found, + "missing": missing, + "metric_details": metric_details, + } + + +def verify_vast_logs(host, hostname_pattern=None): + """Verify VAST syslog entries exist in VictoriaLogs. + + Args: + host: Testinfra host connection to the OIM. + hostname_pattern: Optional hostname pattern to search for. + If None, searches for any VAST-related logs. + + Returns: + dict with keys: success, count, sample_log. + """ + ip, port = get_vlselect_endpoint(host) + if not ip or not port: + return {"success": False, "count": 0, "sample_log": ""} + + # Search for VAST-related logs by hostname or source + if hostname_pattern: + query = f"hostname:{hostname_pattern}*" + else: + # Search for logs from VAST appliance (typically contains "vast" in hostname) + query = "hostname:*vast* OR _msg:*VAST*" + + cmd = CMDS["vl_query_logs"].format( + vlselect_ip=ip, vlselect_port=port, + query=query, limit=5, range="30m", + ) + result = run_on_kube_vip(host, cmd) + if result.rc != 0 or not result.stdout.strip(): + return {"success": False, "count": 0, "sample_log": ""} + + lines = result.stdout.strip().split("\n") + count = len(lines) + sample = "" + if lines: + try: + entry = json.loads(lines[0]) + sample = entry.get("_msg", "")[:120] + except json.JSONDecodeError: + sample = lines[0][:120] + + return {"success": count > 0, "count": count, "sample_log": sample} + + +def get_vast_endpoint_from_config(host): + """Get VAST endpoint from telemetry config. + + Args: + host: Testinfra host connection to the OIM. + + Returns: + dict with keys: success, endpoint, port, metrics_path. + """ + config = load_telemetry_config_from_target(host) + endpoint = read_yaml_key(config, CFG_KEY_VAST_ENDPOINT, default="") + port = read_yaml_key(config, CFG_KEY_VAST_PORT, default=443) + metrics_path = read_yaml_key( + config, "vast_configuration.metrics_path", + default="/api/prometheusmetrics/all" + ) + + return { + "success": bool(endpoint), + "endpoint": endpoint, + "port": str(port), + "metrics_path": metrics_path, + } diff --git a/test/telemetry/library/messages/telemetry_msgs.py b/test/telemetry/library/messages/telemetry_msgs.py index 92692cc228..73b9befb89 100644 --- a/test/telemetry/library/messages/telemetry_msgs.py +++ b/test/telemetry/library/messages/telemetry_msgs.py @@ -118,6 +118,18 @@ "logs_missing": "No log entries found in VictoriaLogs for {source}", "syslog_configured": "PowerScale syslog forwarding configured to {target}", "syslog_not_configured": "PowerScale syslog not forwarding to {target}", + + # VAST + "vast_svc_exists": "VAST external service '{service}' exists with endpoint {endpoint}", + "vast_svc_missing": "VAST external service '{service}' not found", + "vast_vmscrape_exists": "VAST VMServiceScrape '{name}' exists", + "vast_vmscrape_missing": "VAST VMServiceScrape '{name}' not found", + "vast_secret_exists": "VAST credentials secret '{secret}' exists", + "vast_secret_missing": "VAST credentials secret '{secret}' not found", + "vast_metrics_found": "{count} VAST metric(s) found in VictoriaMetrics", + "vast_metrics_missing": "Missing VAST metrics in VictoriaMetrics: {missing}", + "vast_logs_found": "{count} VAST log entries found in VictoriaLogs", + "vast_logs_missing": "No VAST logs found in VictoriaLogs", } # --- Assertion Messages --- @@ -306,4 +318,39 @@ " isi audit settings global modify --system-syslog-servers={target}:514\n" " isi audit settings global modify --protocol-syslog-servers={target}:514\n" ), + + # VAST + "vast_svc_missing": ( + "VAST external service '{service}' not found\n" + "HOW TO FIX:\n" + " 1. kubectl get svc -n telemetry | grep vast\n" + " 2. Re-run telemetry deploy with VAST enabled\n" + ), + "vast_vmscrape_missing": ( + "VAST VMServiceScrape '{name}' not found\n" + "HOW TO FIX:\n" + " 1. kubectl get vmservicescrape -n telemetry | grep vast\n" + " 2. Re-run telemetry deploy with VAST enabled\n" + ), + "vast_secret_missing": ( + "VAST credentials secret '{secret}' not found\n" + "HOW TO FIX:\n" + " 1. kubectl get secret -n telemetry | grep vast\n" + " 2. Re-run telemetry deploy with VAST credentials\n" + ), + "vast_metrics_missing": ( + "VAST metrics not found in VictoriaMetrics: {missing}\n" + "HOW TO FIX:\n" + " 1. Check vmagent scrape targets for VAST\n" + " 2. Verify VAST endpoint is reachable: " + "curl -sk https://:443/api/prometheusmetrics/all\n" + " 3. Check vmagent logs: kubectl logs -n telemetry \n" + ), + "vast_logs_missing": ( + "No VAST logs found in VictoriaLogs\n" + "HOW TO FIX:\n" + " 1. Check VAST syslog config in VAST UI: Settings > Notifications > Syslog Setup\n" + " 2. Verify VLAgent is listening: kubectl get svc vlagent-vlagent -n telemetry\n" + " 3. Check VLAgent logs: kubectl logs vlagent-vlagent-0 -n telemetry\n" + ), } diff --git a/test/telemetry/library/vars/__init__.py b/test/telemetry/library/vars/__init__.py index 0b03c56d88..bf6fa3fdad 100644 --- a/test/telemetry/library/vars/__init__.py +++ b/test/telemetry/library/vars/__init__.py @@ -50,6 +50,10 @@ REQUIRED_CONFIG_FIELDS, REQUIRED_SRC_FILES, CMDS, + VAST_SVC_NAME, + VAST_VMSCRAPE_NAME, + VAST_SECRET_NAME, + VAST_EXPECTED_METRICS, ) from .test_case_vars import TEST_CASES @@ -94,6 +98,10 @@ "REQUIRED_CONFIG_FIELDS", "REQUIRED_SRC_FILES", "CMDS", + "VAST_SVC_NAME", + "VAST_VMSCRAPE_NAME", + "VAST_SECRET_NAME", + "VAST_EXPECTED_METRICS", "TEST_CASES", "VALIDATION_DOMAIN", "FVT_TAGS", diff --git a/test/telemetry/library/vars/common_vars.py b/test/telemetry/library/vars/common_vars.py index fb9a80a3c4..a2549fc984 100644 --- a/test/telemetry/library/vars/common_vars.py +++ b/test/telemetry/library/vars/common_vars.py @@ -183,7 +183,8 @@ # UFM (from deploy_ufm/vars/main.yml) UFM_SVC_NAME = "ufm-external" UFM_VMSCRAPE_NAME = "ufm-infiniband-metrics" -UFM_SECRET_NAME = "ufm-telemetry-credentials" +# K8s Secret object name, not a credential value +UFM_SECRET_NAME = "ufm-telemetry-credentials" # noqa: S105 UFM_EXPECTED_METRICS = [ "infiniband_CBW", "PortXmitDataExtended", @@ -198,6 +199,30 @@ CFG_KEY_UFM_ENDPOINT = "ufm_configuration.ufm_endpoint" CFG_KEY_UFM_PORT = "ufm_configuration.ufm_metrics_port" +# VAST (from deploy_vast/vars/main.yml) +VAST_SVC_NAME = "vast-external" +VAST_VMSCRAPE_NAME = "vast-storage-metrics" +# K8s Secret object name, not a credential value +VAST_SECRET_NAME = "vast-telemetry-credentials" # noqa: S105 +# Expected VAST metrics based on documentation and screenshot +# The screenshot shows: vast_cluster_metrics_EStoreMigrateMetrics_physical_size_count +VAST_EXPECTED_METRICS = [ + "vast_read_throughput", + "vast_write_throughput", + "vast_read_iops", + "vast_write_iops", + "vast_capacity_total_bytes", + "vast_capacity_used_bytes", + "vast_capacity_avail_bytes", + "vast_cluster_metrics_EStoreMigrateMetrics_physical_size_count", +] + +# Telemetry config key paths for VAST +CFG_KEY_VAST_METRICS_ENABLED = "telemetry_sources.vast.metrics_enabled" +CFG_KEY_VAST_LOGS_ENABLED = "telemetry_sources.vast.logs_enabled" +CFG_KEY_VAST_ENDPOINT = "vast_configuration.vast_endpoint" +CFG_KEY_VAST_PORT = "vast_configuration.vast_metrics_port" + # Telemetry sources list TELEMETRY_SOURCES = [ "idrac", "ldms", "dcgm", "powerscale", "ufm", @@ -221,7 +246,6 @@ ) REQUIRED_CONFIG_FIELDS = [ - "project_name", "clone_path", "report_path", "report_name", diff --git a/test/telemetry/library/vars/test_case_vars.py b/test/telemetry/library/vars/test_case_vars.py index e3976df848..16c6aad3d1 100644 --- a/test/telemetry/library/vars/test_case_vars.py +++ b/test/telemetry/library/vars/test_case_vars.py @@ -204,6 +204,28 @@ "title": "Verify UFM InfiniBand metrics in VictoriaMetrics", }, + # -- Sources: VAST ------------------------------------------------------- + "vast_external_svc": { + "id": "TC_SR_060", + "title": "Verify VAST external service exists with correct endpoint", + }, + "vast_vmscrape": { + "id": "TC_SR_061", + "title": "Verify VAST VMServiceScrape CR exists", + }, + "vast_credentials_secret": { + "id": "TC_SR_062", + "title": "Verify VAST credentials K8s secret exists", + }, + "vast_metrics_in_vm": { + "id": "TC_SR_063", + "title": "Verify VAST storage metrics in VictoriaMetrics", + }, + "vast_logs_in_vl": { + "id": "TC_SR_064", + "title": "Verify VAST logs in VictoriaLogs", + }, + # -- Sources: OME ------------------------------------------------------- "ome_vector_bridge": { "id": "TC_SR_050", diff --git a/test/telemetry/setup_env.sh b/test/telemetry/setup_env.sh index bea33a2250..7ea7dd5176 100644 --- a/test/telemetry/setup_env.sh +++ b/test/telemetry/setup_env.sh @@ -14,54 +14,482 @@ # limitations under the License. # ============================================================================= -# telemetry — One-Time Environment Setup +# Telemetry — Test Environment Setup # ============================================================================= -# Creates a Python venv, installs dependencies, and sets up tab-completion. +# Installs test automation dependencies and configures credentials. +# +# INSTALL MODES: +# Baremetal (default) — Install into system Python (pip install --user) +# Active venv — Auto-detected; installs into the currently active venv +# New venv (--venv) — Creates .venv/ and installs there +# +# TWO CREDENTIAL FILES: +# 1. test_creds.yml — SSH password for OIM server access (local). +# 2. telemetry_credentials.yml — Domain credentials (BMC, MySQL, CSI, LDMS, UFM, VAST). +# Created at $OMNIA_DATA_PATH/telemetry/input/$OMNIA_PROJECT_NAME/ +# and encrypted with ansible-vault. +# +# SSH CREDENTIALS: +# --set-creds Interactive prompt (2x confirmation). Asks to update if exists. +# --update-creds Force-update existing SSH password (2x prompt). +# --creds Non-interactive SSH password set. +# +# DOMAIN CREDENTIALS: +# --set-domain-creds Interactive prompt for telemetry domain credentials. +# --update-domain-creds Force-update domain credentials (no "already set" check). +# --domain-creds Non-interactive. JSON: '{"bmc_username":"x",...}' # # Usage: -# source setup_env.sh # Create venv + install deps + activate -# source setup_env.sh -f # Force recreate venv +# bash setup_env.sh # Baremetal or active venv +# bash setup_env.sh --venv # Create .venv/ and install there +# bash setup_env.sh --venv --force # Recreate .venv/ from scratch +# bash setup_env.sh --set-creds # Prompt for SSH password +# bash setup_env.sh --update-creds # Update existing SSH password +# bash setup_env.sh --creds "secret" # Set SSH password via flag +# bash setup_env.sh --set-domain-creds # Prompt for telemetry creds +# bash setup_env.sh --domain-creds '{...}' # Non-interactive domain creds +# bash setup_env.sh --debug # Verbose pip output +# bash setup_env.sh --help # Show this help # ============================================================================= -set -uo pipefail +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VENV_DIR="${SCRIPT_DIR}/.venv" -FORCE=false +REQUIREMENTS="${SCRIPT_DIR}/requirements.txt" -for arg in "$@"; do - case "$arg" in - -f|--force) FORCE=true ;; - esac -done +# ── SSH credentials (local) ── +CREDS_FILE="${SCRIPT_DIR}/test_creds.yml" +CREDS_KEY="${SCRIPT_DIR}/.test_creds.key" + +# ── Domain credentials (at env-var path) ── +DOMAIN_CREDS_FILENAME="telemetry_credentials.yml" +DOMAIN_CREDS_KEY_FILENAME=".telemetry_credentials_key" +DOMAIN_NAME="telemetry" + +# ── omnia_auto credential CLI ── +CRED_CLI="python3 -m omnia_auto" -# Colors +# ───────────────────────────────────────────────────────────────────────────── +# Colors & helpers +# ───────────────────────────────────────────────────────────────────────────── GREEN='\033[0;32m' +RED='\033[0;31m' +BLUE='\033[0;34m' YELLOW='\033[1;33m' +CYAN='\033[0;36m' NC='\033[0m' -# Create venv -if [ "$FORCE" = true ] && [ -d "$VENV_DIR" ]; then - echo -e "${YELLOW}Removing existing venv...${NC}" - rm -rf "$VENV_DIR" +info() { echo -e " ${BLUE}[...]${NC} $1"; } +ok() { echo -e " ${GREEN}[OK]${NC} $1"; } +warn() { echo -e " ${YELLOW}[WARN]${NC} $1"; } +fail() { echo -e " ${RED}[FAIL]${NC} $1"; exit 1; } + +# ───────────────────────────────────────────────────────────────────────────── +# Resolve domain creds path from env vars +# ───────────────────────────────────────────────────────────────────────────── +_resolve_domain_creds_dir() { + local _data_path="${OMNIA_DATA_PATH:-/opt/omnia}" + local _project="${OMNIA_PROJECT_NAME:-project_default}" + echo "${_data_path}/${DOMAIN_NAME}/input/${_project}" +} + +_domain_creds_path() { + echo "$(_resolve_domain_creds_dir)/${DOMAIN_CREDS_FILENAME}" +} + +_domain_creds_key_path() { + echo "$(_resolve_domain_creds_dir)/${DOMAIN_CREDS_KEY_FILENAME}" +} + +# ───────────────────────────────────────────────────────────────────────────── +# Parse arguments +# ───────────────────────────────────────────────────────────────────────────── +USE_VENV=false +FORCE=false +DEBUG=false +PIP_QUIET="--quiet" +SET_CREDS=false +UPDATE_CREDS=false +CREDS_VALUE="" +SET_DOMAIN_CREDS=false +UPDATE_DOMAIN_CREDS=false +DOMAIN_CREDS_JSON="" +TEST_CONFIG="${SCRIPT_DIR}/test_config.yml" + +# shellcheck disable=SC2034 +while [[ $# -gt 0 ]]; do + case "$1" in + --venv) USE_VENV=true; shift ;; + --force|-f) FORCE=true; shift ;; + --debug) DEBUG=true; PIP_QUIET=""; shift ;; + --set-creds) SET_CREDS=true; shift ;; + --update-creds) UPDATE_CREDS=true; shift ;; + --creds) + if [[ $# -lt 2 ]]; then + fail "--creds requires a value. Usage: --creds " + fi + CREDS_VALUE="$2"; shift 2 ;; + --set-domain-creds) SET_DOMAIN_CREDS=true; shift ;; + --update-domain-creds) UPDATE_DOMAIN_CREDS=true; shift ;; + --domain-creds) + if [[ $# -lt 2 ]]; then + fail "--domain-creds requires JSON. Usage: --domain-creds '{\"bmc_username\":\"x\"}'" + fi + DOMAIN_CREDS_JSON="$2"; shift 2 ;; + --help|-h) + cat <<'HELPEOF' + +Telemetry — Test Environment Setup + +Usage: bash setup_env.sh [OPTIONS] + +INSTALL MODES +───────────────────────────────────────────────────────────────── + (no flag) Baremetal mode (pip install --user). + --venv Create .venv/ and install there. + --force, -f With --venv: recreate .venv/ from scratch. + +SSH CREDENTIALS (test_creds.yml) +───────────────────────────────────────────────────────────────── + --set-creds Interactive SSH password setup (2x confirmation). + --update-creds Force-update existing SSH password (2x prompt). + --creds PWD Non-interactive SSH password set. + +DOMAIN CREDENTIALS (telemetry_credentials.yml) +───────────────────────────────────────────────────────────────── + Created at: $OMNIA_DATA_PATH/telemetry/input/$OMNIA_PROJECT_NAME/ + Fields: bmc, mysql, csi, ldms, ufm, vast credentials. + + --set-domain-creds Interactive prompt for all domain fields. + --update-domain-creds Force-update domain creds (no "exists" check). + --domain-creds JSON Non-interactive. Example: + --domain-creds '{"bmc_username":"admin","bmc_password":"pass"}' + +OTHER OPTIONS +───────────────────────────────────────────────────────────────── + --debug Verbose pip output. + --help, -h Show this help. + +HELPEOF + exit 0 ;; + *) + fail "Unknown option: $1 (use --help for usage)" ;; + esac +done + +echo "" +echo "=================================================================" +echo " Telemetry — Test Environment Setup" +echo "=================================================================" +echo "" + +# ───────────────────────────────────────────────────────────────────────────── +# Step 1: Check Python 3.12+ +# ───────────────────────────────────────────────────────────────────────────── +PYTHON_CMD="" +for cmd in python3.12 python3 python; do + if command -v "$cmd" &>/dev/null; then + version=$("$cmd" --version 2>&1 | grep -oP '\d+\.\d+') + major=$(echo "$version" | cut -d. -f1) + minor=$(echo "$version" | cut -d. -f2) + if [ "$major" -ge 3 ] && [ "$minor" -ge 12 ]; then + PYTHON_CMD="$cmd" + break + fi + fi +done + +if [ -z "$PYTHON_CMD" ]; then + fail "Python 3.12+ is required but not found. Install: dnf install python3.12 python3.12-pip" +fi + +ok "Python: $($PYTHON_CMD --version 2>&1)" + +# ───────────────────────────────────────────────────────────────────────────── +# Step 2: Determine install mode +# ───────────────────────────────────────────────────────────────────────────── +INSTALL_MODE="baremetal" +PIP_USER_FLAG="--user" + +if [ "$USE_VENV" = true ]; then + INSTALL_MODE="venv" + PIP_USER_FLAG="" + + if [ "$FORCE" = true ] && [ -d "$VENV_DIR" ]; then + info "Removing existing virtual environment (--force)" + rm -rf "$VENV_DIR" + fi + + if [ -d "$VENV_DIR" ]; then + ok "Virtual environment already exists: .venv/" + else + info "Creating virtual environment: .venv/" + "$PYTHON_CMD" -m venv "$VENV_DIR" + ok "Virtual environment created" + fi + + # shellcheck disable=SC1091 + source "${VENV_DIR}/bin/activate" + ok "Activated .venv/" + +elif [ -n "${VIRTUAL_ENV:-}" ]; then + INSTALL_MODE="active-venv" + PIP_USER_FLAG="" + ok "Detected active virtual environment: ${VIRTUAL_ENV}" + +else + INSTALL_MODE="baremetal" + PIP_USER_FLAG="--user" + ok "Install mode: baremetal (system Python)" +fi + +echo -e " ${CYAN}Mode:${NC} ${INSTALL_MODE}" + +# ───────────────────────────────────────────────────────────────────────────── +# Step 3: Install dependencies +# ───────────────────────────────────────────────────────────────────────────── +info "Upgrading pip" +pip install --upgrade pip $PIP_QUIET $PIP_USER_FLAG 2>/dev/null || \ + pip install --upgrade pip $PIP_QUIET + +info "Installing dependencies from requirements.txt" +pip install -r "$REQUIREMENTS" $PIP_QUIET $PIP_USER_FLAG 2>/dev/null || \ + pip install -r "$REQUIREMENTS" $PIP_QUIET + +if ! pip show pytest-order &>/dev/null; then + info "Installing pytest-order" + pip install pytest-order $PIP_QUIET $PIP_USER_FLAG 2>/dev/null || \ + pip install pytest-order $PIP_QUIET +fi + +ok "All dependencies installed" + +# ───────────────────────────────────────────────────────────────────────────── +# Step 4: Credential helpers (delegate to omnia_auto credential CLI) +# ───────────────────────────────────────────────────────────────────────────── + +_show_oim_server_ip() { + if [ ! -f "$TEST_CONFIG" ]; then + warn "test_config.yml not found — set oim_server_ip for remote mode." + return + fi + local oim_ip + oim_ip=$(grep -E '^oim_server_ip:' "$TEST_CONFIG" 2>/dev/null \ + | sed 's/^oim_server_ip:[[:space:]]*//; s/["'\''[:space:]]//g' || true) + if [ -n "$oim_ip" ]; then + ok "Target server: ${oim_ip}" + else + warn "oim_server_ip not set — credentials saved locally for later use." + fi +} + +# Write SSH creds to test_creds.yml (local) +_write_ssh_creds() { + local _pass="$1" + $CRED_CLI write-fields \ + --creds-path "$CREDS_FILE" --key-path "$CREDS_KEY" \ + --fields "{\"oim_password\":\"${_pass}\"}" >/dev/null 2>&1 + ok "SSH credentials saved: test_creds.yml (encrypted)" +} + +# Write domain creds to telemetry_credentials.yml (at env-var path) +_write_domain_creds() { + local _json="$1" + local _path; _path=$(_domain_creds_path) + local _key; _key=$(_domain_creds_key_path) + local _dir; _dir=$(_resolve_domain_creds_dir) + + mkdir -p "$_dir" + $CRED_CLI write-fields \ + --creds-path "$_path" --key-path "$_key" \ + --fields "$_json" >/dev/null 2>&1 + ok "Domain credentials saved: $_path (encrypted)" +} + +# Read a field from the domain creds file +_read_domain_field() { + local _field="$1" + local _path; _path=$(_domain_creds_path) + local _key; _key=$(_domain_creds_key_path) + $CRED_CLI read-field --creds-path "$_path" --key-path "$_key" \ + --field "$_field" 2>/dev/null || true +} + +# Ask yes/no +_ask_yes_no() { + local prompt="$1" + while true; do + read -r -p "$prompt (yes/no): " answer + case "$answer" in + yes|YES|Yes|y|Y) return 0 ;; + no|NO|No|n|N) return 1 ;; + *) echo -e " ${RED}Please enter 'yes' or 'no'.${NC}" ;; + esac + done +} + +# ───────────────────────────────────────────────────────────────────────────── +# SSH credential dispatch (--set-creds / --update-creds / --creds) +# ───────────────────────────────────────────────────────────────────────────── +if [ -n "$CREDS_VALUE" ]; then + _show_oim_server_ip + info "Setting SSH password from --creds flag" + _write_ssh_creds "$CREDS_VALUE" + +elif [ "$UPDATE_CREDS" = true ]; then + _show_oim_server_ip + if [ ! -f "$CREDS_FILE" ]; then + fail "No credentials file found. Use --set-creds to create one first." + fi + echo -e "\n ${CYAN}Update SSH password for the target OIM server.${NC}\n" + _cred_input=$($CRED_CLI prompt-and-confirm --message "SSH Password") + _write_ssh_creds "$_cred_input" + +elif [ "$SET_CREDS" = true ]; then + _show_oim_server_ip + if [ -f "$CREDS_FILE" ]; then + warn "SSH password is already set (test_creds.yml exists)." + if _ask_yes_no " Do you want to update the SSH password?"; then + echo -e "\n ${CYAN}Enter new SSH password for the target OIM server.${NC}\n" + _cred_input=$($CRED_CLI prompt-and-confirm --message "SSH Password") + _write_ssh_creds "$_cred_input" + else + ok "SSH password update skipped." + fi + else + echo -e "\n ${CYAN}Enter SSH password for the target OIM server.${NC}\n" + _cred_input=$($CRED_CLI prompt-and-confirm --message "SSH Password") + _write_ssh_creds "$_cred_input" + fi fi -if [ ! -d "$VENV_DIR" ]; then - echo -e "${GREEN}Creating Python venv at ${VENV_DIR}...${NC}" - python3 -m venv "$VENV_DIR" +# ───────────────────────────────────────────────────────────────────────────── +# Domain credential dispatch (--set-domain-creds / --update-domain-creds / --domain-creds) +# ───────────────────────────────────────────────────────────────────────────── +if [ -n "$DOMAIN_CREDS_JSON" ]; then + info "Setting domain credentials from --domain-creds flag" + _write_domain_creds "$DOMAIN_CREDS_JSON" + +elif [ "$UPDATE_DOMAIN_CREDS" = true ] || [ "$SET_DOMAIN_CREDS" = true ]; then + _domain_path=$(_domain_creds_path) + + if [ "$SET_DOMAIN_CREDS" = true ] && [ -f "$_domain_path" ]; then + warn "Domain credentials already exist: $_domain_path" + if ! _ask_yes_no " Do you want to update domain credentials?"; then + ok "Domain credential update skipped." + SET_DOMAIN_CREDS=false + fi + fi + + if [ "$UPDATE_DOMAIN_CREDS" = true ] || [ "$SET_DOMAIN_CREDS" = true ]; then + echo "" + echo -e " ${CYAN}Telemetry Domain Credentials${NC}" + echo -e " ${CYAN}Press Enter to keep existing value (shown in brackets).${NC}" + echo "" + + # Read existing values + _e_bmc_user=$(_read_domain_field "bmc_username") + _e_bmc_pass=$(_read_domain_field "bmc_password") + _e_mysql_user=$(_read_domain_field "mysqldb_user") + _e_mysql_pass=$(_read_domain_field "mysqldb_password") + _e_mysql_root=$(_read_domain_field "mysqldb_root_password") + _e_csi_user=$(_read_domain_field "csi_username") + _e_csi_pass=$(_read_domain_field "csi_password") + _e_ldms_pass=$(_read_domain_field "ldms_sampler_password") + _e_ufm_user=$(_read_domain_field "ufm_username") + _e_ufm_pass=$(_read_domain_field "ufm_password") + _e_vast_user=$(_read_domain_field "vast_username") + _e_vast_pass=$(_read_domain_field "vast_password") + + # BMC + echo -e " ${YELLOW}iDRAC BMC Credentials:${NC}" + _p=" BMC Username"; [ -n "$_e_bmc_user" ] && _p="${_p} [${_e_bmc_user}]" + read -r -p "${_p}: " _n; _bmc_user="${_n:-$_e_bmc_user}" + read -s -r -p " BMC Password: " _n; echo ""; _bmc_pass="${_n:-$_e_bmc_pass}" + + # MySQL + echo -e "\n ${YELLOW}MySQL Database Credentials:${NC}" + _p=" MySQL User"; [ -n "$_e_mysql_user" ] && _p="${_p} [${_e_mysql_user}]" + read -r -p "${_p}: " _n; _mysql_user="${_n:-$_e_mysql_user}" + read -s -r -p " MySQL Password: " _n; echo ""; _mysql_pass="${_n:-$_e_mysql_pass}" + read -s -r -p " MySQL Root Password: " _n; echo ""; _mysql_root="${_n:-$_e_mysql_root}" + + # CSI + echo -e "\n ${YELLOW}PowerScale CSI Credentials:${NC}" + _p=" CSI Username"; [ -n "$_e_csi_user" ] && _p="${_p} [${_e_csi_user}]" + read -r -p "${_p}: " _n; _csi_user="${_n:-$_e_csi_user}" + read -s -r -p " CSI Password: " _n; echo ""; _csi_pass="${_n:-$_e_csi_pass}" + + # LDMS + echo -e "\n ${YELLOW}LDMS Sampler Credentials:${NC}" + read -s -r -p " LDMS Sampler Password: " _n; echo ""; _ldms_pass="${_n:-$_e_ldms_pass}" + + # UFM + echo -e "\n ${YELLOW}UFM Telemetry Credentials:${NC}" + _p=" UFM Username"; [ -n "$_e_ufm_user" ] && _p="${_p} [${_e_ufm_user}]" + read -r -p "${_p}: " _n; _ufm_user="${_n:-$_e_ufm_user}" + read -s -r -p " UFM Password: " _n; echo ""; _ufm_pass="${_n:-$_e_ufm_pass}" + + # VAST + echo -e "\n ${YELLOW}VAST Telemetry Credentials:${NC}" + _p=" VAST Username"; [ -n "$_e_vast_user" ] && _p="${_p} [${_e_vast_user}]" + read -r -p "${_p}: " _n; _vast_user="${_n:-$_e_vast_user}" + read -s -r -p " VAST Password: " _n; echo ""; _vast_pass="${_n:-$_e_vast_pass}" + + # Build JSON and write + _json=$(python3 -c " +import json, sys +d = {} +pairs = [ + ('bmc_username', '${_bmc_user}'), ('bmc_password', '${_bmc_pass}'), + ('mysqldb_user', '${_mysql_user}'), ('mysqldb_password', '${_mysql_pass}'), + ('mysqldb_root_password', '${_mysql_root}'), + ('csi_username', '${_csi_user}'), ('csi_password', '${_csi_pass}'), + ('ldms_sampler_password', '${_ldms_pass}'), + ('ufm_username', '${_ufm_user}'), ('ufm_password', '${_ufm_pass}'), + ('vast_username', '${_vast_user}'), ('vast_password', '${_vast_pass}'), +] +for k, v in pairs: + if v: + d[k] = v +print(json.dumps(d)) +") + echo "" + _write_domain_creds "$_json" + fi fi -# Activate -# shellcheck disable=SC1091 -source "${VENV_DIR}/bin/activate" +# ───────────────────────────────────────────────────────────────────────────── +# No credential flags — status report +# ───────────────────────────────────────────────────────────────────────────── +if [ -z "$CREDS_VALUE" ] && [ "$UPDATE_CREDS" = false ] && [ "$SET_CREDS" = false ] \ + && [ -z "$DOMAIN_CREDS_JSON" ] && [ "$SET_DOMAIN_CREDS" = false ] \ + && [ "$UPDATE_DOMAIN_CREDS" = false ]; then + if [ -f "$CREDS_FILE" ]; then + ok "SSH credentials: test_creds.yml (encrypted)" + else + warn "No SSH credentials (test_creds.yml)" + warn " Set with: bash setup_env.sh --set-creds" + fi + _dc=$(_domain_creds_path) + if [ -f "$_dc" ]; then + ok "Domain credentials: $_dc (encrypted)" + else + warn "No domain credentials: $_dc" + warn " Set with: bash setup_env.sh --set-domain-creds" + fi +fi -# Install deps -echo -e "${GREEN}Installing dependencies...${NC}" -pip install --upgrade pip -q -pip install -r "${SCRIPT_DIR}/requirements.txt" -q +# ───────────────────────────────────────────────────────────────────────────── +# Step 5: Make scripts executable +# ───────────────────────────────────────────────────────────────────────────── +chmod +x "${SCRIPT_DIR}/run_validation.sh" 2>/dev/null || true +# ───────────────────────────────────────────────────────────────────────────── # Tab-completion for run_validation.sh -# Usage: ./run_validation.sh telemetry [tag] [options] +# ───────────────────────────────────────────────────────────────────────────── +# shellcheck disable=SC2207 _run_validation_completions() { local cur="${COMP_WORDS[COMP_CWORD]}" local prev="${COMP_WORDS[COMP_CWORD-1]}" @@ -73,15 +501,12 @@ _run_validation_completions() { case "$COMP_CWORD" in 1) - # First arg is always the domain name COMPREPLY=( $(compgen -W "${domain} --config help --completion" -- "$cur") ) ;; 2) - # After domain: tag or command COMPREPLY=( $(compgen -W "${tags} ${commands}" -- "$cur") ) ;; 3) - # After tag: command; after command: options if echo " ${tags} " | grep -q " ${prev} "; then COMPREPLY=( $(compgen -W "${commands}" -- "$cur") ) else @@ -91,25 +516,20 @@ _run_validation_completions() { *) case "$prev" in --suite) - local suites="" - local tag_dir="" + local suites="" tag_dir="" for w in "${COMP_WORDS[@]}"; do if echo " ${tags} " | grep -q " ${w} "; then - tag_dir="${SCRIPT_DIR}/fvt/${w}" - break + tag_dir="${SCRIPT_DIR}/fvt/${w}"; break fi done if [ -n "${tag_dir}" ] && [ -d "${tag_dir}" ]; then - suites=$(find "${tag_dir}" -mindepth 1 -maxdepth 1 -type d -not -name '__pycache__' -printf '%f\n' 2>/dev/null || true) + suites=$(find "${tag_dir}" -mindepth 1 -maxdepth 1 -type d \ + -not -name '__pycache__' -printf '%f\n' 2>/dev/null || true) fi COMPREPLY=( $(compgen -W "${suites}" -- "$cur") ) ;; - --marker) - COMPREPLY=( $(compgen -W "${markers}" -- "$cur") ) - ;; - *) - COMPREPLY=( $(compgen -W "${options}" -- "$cur") ) - ;; + --marker) COMPREPLY=( $(compgen -W "${markers}" -- "$cur") ) ;; + *) COMPREPLY=( $(compgen -W "${options}" -- "$cur") ) ;; esac ;; esac @@ -117,5 +537,55 @@ _run_validation_completions() { complete -F _run_validation_completions ./run_validation.sh -echo -e "${GREEN}Environment ready. Tab-completion enabled.${NC}" -echo -e "${GREEN}Run: ./run_validation.sh telemetry [tag] ${NC}" +# ───────────────────────────────────────────────────────────────────────────── +# Summary +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}=================================================================${NC}" +echo -e "${GREEN} Environment Ready (${INSTALL_MODE})${NC}" +echo -e "${GREEN}=================================================================${NC}" +echo "" + +case "$INSTALL_MODE" in + venv) + echo " Next steps:" + echo " source .venv/bin/activate" + echo " ./run_validation.sh --help" + echo " ./run_validation.sh telemetry list" + ;; + active-venv) + echo " Next steps (venv already active):" + echo " ./run_validation.sh --help" + echo " ./run_validation.sh telemetry list" + ;; + baremetal) + echo " Next steps:" + echo " ./run_validation.sh --help" + echo " ./run_validation.sh telemetry list" + ;; +esac + +echo "" +echo " Credentials (two separate files):" +echo "" +echo " 1. SSH credentials (test_creds.yml) — for remote test execution:" +if [ -f "$CREDS_FILE" ]; then + echo " test_creds.yml exists (encrypted)" + echo " To update: bash setup_env.sh --update-creds" +else + echo " Not set. Create with: bash setup_env.sh --set-creds" +fi +echo "" +echo " 2. Telemetry domain credentials:" +_dc_summary=$(_domain_creds_path) +if [ -f "$_dc_summary" ]; then + echo " ${_dc_summary} (encrypted)" + echo " To update: bash setup_env.sh --update-domain-creds" +else + echo " Not set. Create with: bash setup_env.sh --set-domain-creds" +fi +echo "" +echo " Tab-completion enabled for ./run_validation.sh" +echo "" +echo "=================================================================" +echo "" diff --git a/test/telemetry/test_config.yml b/test/telemetry/test_config.yml index 86c2cd66d3..77f1ae2049 100644 --- a/test/telemetry/test_config.yml +++ b/test/telemetry/test_config.yml @@ -58,11 +58,6 @@ oim_ssh_port: 22 # resolved automatically from the source tree. clone_path: "/root/monorepo/omnia" -# venv_path: Path to Python virtual environment on the target server. -# If set, the venv is activated before running ansible-playbook. -# Leave empty to use system-wide ansible. -venv_path: "" - # ============================================================================= # DATASET CONFIGURATION # ============================================================================= @@ -82,12 +77,6 @@ venv_path: "" # input/telemetry_packages.yml dataset: "" -# Project name on the target server. -# Must match OMNIA_PROJECT_NAME env var on the target (default: project_default). -# Input read from: /telemetry/input// -# Output read from: /telemetry/output// -project_name: "project_default" - # ============================================================================= # SYNC — TELEMETRY INPUT # =============================================================================