From 3e40035b05de026a6d195402938faf2198b5114d Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Fri, 20 Feb 2026 13:20:28 -0500 Subject: [PATCH 01/22] all slurm metrics --- azure-slurm-exporter/azslurm_metrics_curl.txt | 489 ++++++++++++++++++ .../current_dashboard_metrics.txt | 11 + 2 files changed, 500 insertions(+) create mode 100644 azure-slurm-exporter/azslurm_metrics_curl.txt create mode 100644 azure-slurm-exporter/current_dashboard_metrics.txt diff --git a/azure-slurm-exporter/azslurm_metrics_curl.txt b/azure-slurm-exporter/azslurm_metrics_curl.txt new file mode 100644 index 00000000..2b1d2c28 --- /dev/null +++ b/azure-slurm-exporter/azslurm_metrics_curl.txt @@ -0,0 +1,489 @@ +cc-admin@azslurm-exporter-scheduler:~$ curl -s localhost:9500/metrics +# HELP python_gc_objects_collected_total Objects collected during gc +# TYPE python_gc_objects_collected_total counter +python_gc_objects_collected_total{generation="0"} 313.0 +python_gc_objects_collected_total{generation="1"} 49.0 +python_gc_objects_collected_total{generation="2"} 289.0 +# HELP python_gc_objects_uncollectable_total Uncollectable objects found during GC +# TYPE python_gc_objects_uncollectable_total counter +python_gc_objects_uncollectable_total{generation="0"} 0.0 +python_gc_objects_uncollectable_total{generation="1"} 0.0 +python_gc_objects_uncollectable_total{generation="2"} 0.0 +# HELP python_gc_collections_total Number of times this generation was collected +# TYPE python_gc_collections_total counter +python_gc_collections_total{generation="0"} 89481.0 +python_gc_collections_total{generation="1"} 8134.0 +python_gc_collections_total{generation="2"} 729.0 +# HELP python_info Python platform information +# TYPE python_info gauge +python_info{implementation="CPython",major="3",minor="11",patchlevel="0rc1",version="3.11.0rc1"} 1.0 +# HELP process_virtual_memory_bytes Virtual memory size in bytes. +# TYPE process_virtual_memory_bytes gauge +process_virtual_memory_bytes 6.14395904e+08 +# HELP process_resident_memory_bytes Resident memory size in bytes. +# TYPE process_resident_memory_bytes gauge +process_resident_memory_bytes 3.106816e+07 +# HELP process_start_time_seconds Start time of the process since unix epoch in seconds. +# TYPE process_start_time_seconds gauge +process_start_time_seconds 1.77144788156e+09 +# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds. +# TYPE process_cpu_seconds_total counter +process_cpu_seconds_total 200.66 +# HELP process_open_fds Number of open file descriptors. +# TYPE process_open_fds gauge +process_open_fds 6.0 +# HELP process_max_fds Maximum number of open file descriptors. +# TYPE process_max_fds gauge +process_max_fds 1024.0 +# HELP squeue_jobs Slurm job queue metric: squeue_jobs +# TYPE squeue_jobs gauge +squeue_jobs 0.0 +# HELP squeue_jobs_running Slurm job queue metric: squeue_jobs_running +# TYPE squeue_jobs_running gauge +squeue_jobs_running 0.0 +# HELP squeue_jobs_pending Slurm job queue metric: squeue_jobs_pending +# TYPE squeue_jobs_pending gauge +squeue_jobs_pending 0.0 +# HELP squeue_jobs_configuring Slurm job queue metric: squeue_jobs_configuring +# TYPE squeue_jobs_configuring gauge +squeue_jobs_configuring 0.0 +# HELP squeue_jobs_completing Slurm job queue metric: squeue_jobs_completing +# TYPE squeue_jobs_completing gauge +squeue_jobs_completing 0.0 +# HELP squeue_jobs_suspended Slurm job queue metric: squeue_jobs_suspended +# TYPE squeue_jobs_suspended gauge +squeue_jobs_suspended 0.0 +# HELP squeue_jobs_failed Slurm job queue metric: squeue_jobs_failed +# TYPE squeue_jobs_failed gauge +squeue_jobs_failed 0.0 +# HELP squeue_job_nodes_allocated Number of nodes allocated/requested per job +# TYPE squeue_job_nodes_allocated gauge +# HELP sacct_jobs_total_completed Slurm cumulative job metric: sacct_jobs_total_completed +# TYPE sacct_jobs_total_completed gauge +sacct_jobs_total_completed 223.0 +# HELP sacct_jobs_total_failed Slurm cumulative job metric: sacct_jobs_total_failed +# TYPE sacct_jobs_total_failed gauge +sacct_jobs_total_failed 51.0 +# HELP sacct_jobs_total_timeout Slurm cumulative job metric: sacct_jobs_total_timeout +# TYPE sacct_jobs_total_timeout gauge +sacct_jobs_total_timeout 23.0 +# HELP sacct_jobs_total_cancelled Slurm cumulative job metric: sacct_jobs_total_cancelled +# TYPE sacct_jobs_total_cancelled gauge +sacct_jobs_total_cancelled 3.0 +# HELP sacct_jobs_total_node_failed Slurm cumulative job metric: sacct_jobs_total_node_failed +# TYPE sacct_jobs_total_node_failed gauge +sacct_jobs_total_node_failed 0.0 +# HELP sacct_jobs_total_out_of_memory Slurm cumulative job metric: sacct_jobs_total_out_of_memory +# TYPE sacct_jobs_total_out_of_memory gauge +sacct_jobs_total_out_of_memory 0.0 +# HELP sacct_jobs_total_submitted Slurm cumulative job metric: sacct_jobs_total_submitted +# TYPE sacct_jobs_total_submitted gauge +sacct_jobs_total_submitted 301.0 +# HELP scontrol_nodes Slurm node metric: scontrol_nodes +# TYPE scontrol_nodes gauge +scontrol_nodes 88.0 +# HELP scontrol_nodes_powered_down Slurm node metric: scontrol_nodes_powered_down +# TYPE scontrol_nodes_powered_down gauge +scontrol_nodes_powered_down 72.0 +# HELP scontrol_nodes_powering_up Slurm node metric: scontrol_nodes_powering_up +# TYPE scontrol_nodes_powering_up gauge +scontrol_nodes_powering_up 0.0 +# HELP scontrol_nodes_down Slurm node metric: scontrol_nodes_down +# TYPE scontrol_nodes_down gauge +scontrol_nodes_down 0.0 +# HELP scontrol_nodes_fail Slurm node metric: scontrol_nodes_fail +# TYPE scontrol_nodes_fail gauge +scontrol_nodes_fail 0.0 +# HELP scontrol_nodes_drained Slurm node metric: scontrol_nodes_drained +# TYPE scontrol_nodes_drained gauge +scontrol_nodes_drained 3.0 +# HELP scontrol_nodes_draining Slurm node metric: scontrol_nodes_draining +# TYPE scontrol_nodes_draining gauge +scontrol_nodes_draining 0.0 +# HELP scontrol_nodes_maint Slurm node metric: scontrol_nodes_maint +# TYPE scontrol_nodes_maint gauge +scontrol_nodes_maint 0.0 +# HELP scontrol_nodes_resv Slurm node metric: scontrol_nodes_resv +# TYPE scontrol_nodes_resv gauge +scontrol_nodes_resv 0.0 +# HELP scontrol_nodes_completing Slurm node metric: scontrol_nodes_completing +# TYPE scontrol_nodes_completing gauge +scontrol_nodes_completing 0.0 +# HELP scontrol_nodes_alloc Slurm node metric: scontrol_nodes_alloc +# TYPE scontrol_nodes_alloc gauge +scontrol_nodes_alloc 0.0 +# HELP scontrol_nodes_mixed Slurm node metric: scontrol_nodes_mixed +# TYPE scontrol_nodes_mixed gauge +scontrol_nodes_mixed 0.0 +# HELP scontrol_nodes_idle Slurm node metric: scontrol_nodes_idle +# TYPE scontrol_nodes_idle gauge +scontrol_nodes_idle 13.0 +# HELP scontrol_nodes_cloud Slurm node metric: scontrol_nodes_cloud +# TYPE scontrol_nodes_cloud gauge +scontrol_nodes_cloud 88.0 +# HELP squeue_partition_jobs Slurm partition job metric: squeue_partition_jobs +# TYPE squeue_partition_jobs gauge +squeue_partition_jobs{partition="dynamic"} 0.0 +squeue_partition_jobs{partition="gpu"} 0.0 +squeue_partition_jobs{partition="hpc"} 0.0 +squeue_partition_jobs{partition="htc"} 0.0 +# HELP squeue_partition_jobs_running Slurm partition job metric: squeue_partition_jobs_running +# TYPE squeue_partition_jobs_running gauge +squeue_partition_jobs_running{partition="dynamic"} 0.0 +squeue_partition_jobs_running{partition="gpu"} 0.0 +squeue_partition_jobs_running{partition="hpc"} 0.0 +squeue_partition_jobs_running{partition="htc"} 0.0 +# HELP squeue_partition_jobs_pending Slurm partition job metric: squeue_partition_jobs_pending +# TYPE squeue_partition_jobs_pending gauge +squeue_partition_jobs_pending{partition="dynamic"} 0.0 +squeue_partition_jobs_pending{partition="gpu"} 0.0 +squeue_partition_jobs_pending{partition="hpc"} 0.0 +squeue_partition_jobs_pending{partition="htc"} 0.0 +# HELP squeue_partition_jobs_configuring Slurm partition job metric: squeue_partition_jobs_configuring +# TYPE squeue_partition_jobs_configuring gauge +squeue_partition_jobs_configuring{partition="dynamic"} 0.0 +squeue_partition_jobs_configuring{partition="gpu"} 0.0 +squeue_partition_jobs_configuring{partition="hpc"} 0.0 +squeue_partition_jobs_configuring{partition="htc"} 0.0 +# HELP squeue_partition_jobs_completing Slurm partition job metric: squeue_partition_jobs_completing +# TYPE squeue_partition_jobs_completing gauge +squeue_partition_jobs_completing{partition="dynamic"} 0.0 +squeue_partition_jobs_completing{partition="gpu"} 0.0 +squeue_partition_jobs_completing{partition="hpc"} 0.0 +squeue_partition_jobs_completing{partition="htc"} 0.0 +# HELP squeue_partition_jobs_suspended Slurm partition job metric: squeue_partition_jobs_suspended +# TYPE squeue_partition_jobs_suspended gauge +squeue_partition_jobs_suspended{partition="dynamic"} 0.0 +squeue_partition_jobs_suspended{partition="gpu"} 0.0 +squeue_partition_jobs_suspended{partition="hpc"} 0.0 +squeue_partition_jobs_suspended{partition="htc"} 0.0 +# HELP squeue_partition_jobs_failed Slurm partition job metric: squeue_partition_jobs_failed +# TYPE squeue_partition_jobs_failed gauge +squeue_partition_jobs_failed{partition="dynamic"} 0.0 +squeue_partition_jobs_failed{partition="gpu"} 0.0 +squeue_partition_jobs_failed{partition="hpc"} 0.0 +squeue_partition_jobs_failed{partition="htc"} 0.0 +# HELP scontrol_partition_nodes Total nodes per partition +# TYPE scontrol_partition_nodes gauge +scontrol_partition_nodes{partition="dynamic"} 21.0 +scontrol_partition_nodes{partition="gpu"} 1.0 +scontrol_partition_nodes{partition="hpc"} 16.0 +scontrol_partition_nodes{partition="htc"} 50.0 +# HELP scontrol_partition_nodes_powered_down Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_powered_down +# TYPE scontrol_partition_nodes_powered_down gauge +scontrol_partition_nodes_powered_down{nodelist="dyn1-[1-10],dyn2-[1-10],g1",partition="dynamic",reason="none"} 21.0 +scontrol_partition_nodes_powered_down{nodelist="azslurm-exporter-gpu-1",partition="gpu",reason="none"} 1.0 +scontrol_partition_nodes_powered_down{nodelist="azslurm-exporter-htc-[1-50]",partition="htc",reason="none"} 50.0 +scontrol_partition_nodes_powered_down{nodelist="none",partition="hpc",reason="none"} 0.0 +# HELP scontrol_partition_nodes_powering_up Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_powering_up +# TYPE scontrol_partition_nodes_powering_up gauge +scontrol_partition_nodes_powering_up{nodelist="none",partition="hpc",reason="none"} 0.0 +scontrol_partition_nodes_powering_up{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_powering_up{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_powering_up{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP scontrol_partition_nodes_down Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_down +# TYPE scontrol_partition_nodes_down gauge +scontrol_partition_nodes_down{nodelist="none",partition="hpc",reason="none"} 0.0 +scontrol_partition_nodes_down{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_down{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_down{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP scontrol_partition_nodes_fail Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_fail +# TYPE scontrol_partition_nodes_fail gauge +scontrol_partition_nodes_fail{nodelist="none",partition="hpc",reason="none"} 0.0 +scontrol_partition_nodes_fail{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_fail{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_fail{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP scontrol_partition_nodes_drained Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_drained +# TYPE scontrol_partition_nodes_drained gauge +scontrol_partition_nodes_drained{nodelist="azslurm-exporter-hpc-[1-3]",partition="hpc",reason="blah"} 3.0 +scontrol_partition_nodes_drained{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_drained{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_drained{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP scontrol_partition_nodes_draining Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_draining +# TYPE scontrol_partition_nodes_draining gauge +scontrol_partition_nodes_draining{nodelist="none",partition="hpc",reason="none"} 0.0 +scontrol_partition_nodes_draining{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_draining{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_draining{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP scontrol_partition_nodes_maint Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_maint +# TYPE scontrol_partition_nodes_maint gauge +scontrol_partition_nodes_maint{nodelist="none",partition="hpc",reason="none"} 0.0 +scontrol_partition_nodes_maint{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_maint{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_maint{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP scontrol_partition_nodes_resv Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_resv +# TYPE scontrol_partition_nodes_resv gauge +scontrol_partition_nodes_resv{nodelist="none",partition="hpc",reason="none"} 0.0 +scontrol_partition_nodes_resv{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_resv{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_resv{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP scontrol_partition_nodes_completing Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_completing +# TYPE scontrol_partition_nodes_completing gauge +scontrol_partition_nodes_completing{nodelist="none",partition="hpc",reason="none"} 0.0 +scontrol_partition_nodes_completing{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_completing{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_completing{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP scontrol_partition_nodes_alloc Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_alloc +# TYPE scontrol_partition_nodes_alloc gauge +scontrol_partition_nodes_alloc{nodelist="none",partition="hpc",reason="none"} 0.0 +scontrol_partition_nodes_alloc{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_alloc{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_alloc{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP scontrol_partition_nodes_mixed Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_mixed +# TYPE scontrol_partition_nodes_mixed gauge +scontrol_partition_nodes_mixed{nodelist="none",partition="hpc",reason="none"} 0.0 +scontrol_partition_nodes_mixed{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_mixed{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_mixed{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP scontrol_partition_nodes_idle Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_idle +# TYPE scontrol_partition_nodes_idle gauge +scontrol_partition_nodes_idle{nodelist="azslurm-exporter-hpc-[4-16]",partition="hpc",reason="none"} 13.0 +scontrol_partition_nodes_idle{nodelist="none",partition="htc",reason="none"} 0.0 +scontrol_partition_nodes_idle{nodelist="none",partition="dynamic",reason="none"} 0.0 +scontrol_partition_nodes_idle{nodelist="none",partition="gpu",reason="none"} 0.0 +# HELP sacct_partition_jobs_total_completed Slurm partition cumulative job metric: sacct_partition_jobs_total_completed +# TYPE sacct_partition_jobs_total_completed gauge +sacct_partition_jobs_total_completed{partition="hpc"} 138.0 +sacct_partition_jobs_total_completed{partition="htc"} 80.0 +sacct_partition_jobs_total_completed{partition="dynamic"} 5.0 +# HELP sacct_partition_jobs_total_failed Slurm partition cumulative job metric: sacct_partition_jobs_total_failed +# TYPE sacct_partition_jobs_total_failed gauge +sacct_partition_jobs_total_failed{partition="hpc"} 29.0 +sacct_partition_jobs_total_failed{partition="htc"} 22.0 +# HELP sacct_partition_jobs_total_timeout Slurm partition cumulative job metric: sacct_partition_jobs_total_timeout +# TYPE sacct_partition_jobs_total_timeout gauge +sacct_partition_jobs_total_timeout{partition="hpc"} 15.0 +sacct_partition_jobs_total_timeout{partition="htc"} 8.0 +# HELP sacct_partition_jobs_total_submitted Slurm partition cumulative job metric: sacct_partition_jobs_total_submitted +# TYPE sacct_partition_jobs_total_submitted gauge +sacct_partition_jobs_total_submitted{partition="hpc"} 182.0 +sacct_partition_jobs_total_submitted{partition="htc"} 112.0 +sacct_partition_jobs_total_submitted{partition="dynamic"} 6.0 +sacct_partition_jobs_total_submitted{partition="gpu"} 1.0 +# HELP sacct_partition_jobs_total_cancelled Slurm partition cumulative job metric: sacct_partition_jobs_total_cancelled +# TYPE sacct_partition_jobs_total_cancelled gauge +sacct_partition_jobs_total_cancelled{partition="htc"} 2.0 +sacct_partition_jobs_total_cancelled{partition="gpu"} 1.0 +# HELP sacct_partition_jobs_total_node_failed Slurm partition cumulative job metric: sacct_partition_jobs_total_node_failed +# TYPE sacct_partition_jobs_total_node_failed gauge +sacct_partition_jobs_total_node_failed{partition="dynamic"} 1.0 +# HELP sacct_jobs_total_six_months_submitted Slurm 6-month rolling job metric: submitted +# TYPE sacct_jobs_total_six_months_submitted gauge +sacct_jobs_total_six_months_submitted{start_date="2025-08-23"} 301.0 +# HELP sacct_jobs_total_six_months_completed Slurm 6-month rolling job metric: completed +# TYPE sacct_jobs_total_six_months_completed gauge +sacct_jobs_total_six_months_completed{start_date="2025-08-23"} 223.0 +# HELP sacct_jobs_total_six_months_failed Slurm 6-month rolling job metric: failed +# TYPE sacct_jobs_total_six_months_failed gauge +sacct_jobs_total_six_months_failed{start_date="2025-08-23"} 51.0 +# HELP sacct_jobs_total_six_months_timeout Slurm 6-month rolling job metric: timeout +# TYPE sacct_jobs_total_six_months_timeout gauge +sacct_jobs_total_six_months_timeout{start_date="2025-08-23"} 23.0 +# HELP sacct_jobs_total_six_months_node_failed Slurm 6-month rolling job metric: node_failed +# TYPE sacct_jobs_total_six_months_node_failed gauge +sacct_jobs_total_six_months_node_failed{start_date="2025-08-23"} 1.0 +# HELP sacct_jobs_total_six_months_cancelled Slurm 6-month rolling job metric: cancelled +# TYPE sacct_jobs_total_six_months_cancelled gauge +sacct_jobs_total_six_months_cancelled{start_date="2025-08-23"} 3.0 +# HELP sacct_jobs_total_six_months_by_state Slurm 6-month rolling job metric by state +# TYPE sacct_jobs_total_six_months_by_state gauge +sacct_jobs_total_six_months_by_state{start_date="2025-08-23",state="completed"} 223.0 +sacct_jobs_total_six_months_by_state{start_date="2025-08-23",state="failed"} 51.0 +sacct_jobs_total_six_months_by_state{start_date="2025-08-23",state="timeout"} 23.0 +sacct_jobs_total_six_months_by_state{start_date="2025-08-23",state="node_failed"} 1.0 +sacct_jobs_total_six_months_by_state{start_date="2025-08-23",state="cancelled"} 3.0 +# HELP sacct_jobs_total_six_months_by_state_exit_code Slurm 6-month rolling job metric by state and exit code +# TYPE sacct_jobs_total_six_months_by_state_exit_code gauge +sacct_jobs_total_six_months_by_state_exit_code{exit_code="1:0",reason="General failure",start_date="2025-08-23",state="failed"} 27.0 +sacct_jobs_total_six_months_by_state_exit_code{exit_code="127:0",reason="Command not found",start_date="2025-08-23",state="failed"} 11.0 +sacct_jobs_total_six_months_by_state_exit_code{exit_code="2:0",reason="Misuse of shell built-in",start_date="2025-08-23",state="failed"} 4.0 +sacct_jobs_total_six_months_by_state_exit_code{exit_code="137:0",reason="SIGKILL - Force killed",start_date="2025-08-23",state="failed"} 4.0 +sacct_jobs_total_six_months_by_state_exit_code{exit_code="143:0",reason="SIGTERM - Terminated",start_date="2025-08-23",state="failed"} 3.0 +sacct_jobs_total_six_months_by_state_exit_code{exit_code="42:0",reason="Other",start_date="2025-08-23",state="failed"} 1.0 +sacct_jobs_total_six_months_by_state_exit_code{exit_code="255:0",reason="Other",start_date="2025-08-23",state="failed"} 1.0 +sacct_jobs_total_six_months_by_state_exit_code{exit_code="0:0",reason="",start_date="2025-08-23",state="timeout"} 23.0 +sacct_jobs_total_six_months_by_state_exit_code{exit_code="1:0",reason="General failure",start_date="2025-08-23",state="node_failed"} 1.0 +sacct_jobs_total_six_months_by_state_exit_code{exit_code="0:0",reason="",start_date="2025-08-23",state="cancelled"} 3.0 +# HELP sacct_partition_jobs_total_six_months_submitted Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_submitted +# TYPE sacct_partition_jobs_total_six_months_submitted gauge +sacct_partition_jobs_total_six_months_submitted{partition="hpc",start_date="2025-08-23"} 182.0 +sacct_partition_jobs_total_six_months_submitted{partition="htc",start_date="2025-08-23"} 112.0 +sacct_partition_jobs_total_six_months_submitted{partition="dynamic",start_date="2025-08-23"} 6.0 +sacct_partition_jobs_total_six_months_submitted{partition="gpu",start_date="2025-08-23"} 1.0 +# HELP sacct_partition_jobs_total_six_months_completed Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_completed +# TYPE sacct_partition_jobs_total_six_months_completed gauge +sacct_partition_jobs_total_six_months_completed{partition="hpc",start_date="2025-08-23"} 138.0 +sacct_partition_jobs_total_six_months_completed{partition="htc",start_date="2025-08-23"} 80.0 +sacct_partition_jobs_total_six_months_completed{partition="dynamic",start_date="2025-08-23"} 5.0 +# HELP sacct_partition_jobs_total_six_months_failed Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_failed +# TYPE sacct_partition_jobs_total_six_months_failed gauge +sacct_partition_jobs_total_six_months_failed{partition="hpc",start_date="2025-08-23"} 29.0 +sacct_partition_jobs_total_six_months_failed{partition="htc",start_date="2025-08-23"} 22.0 +# HELP sacct_partition_jobs_total_six_months_timeout Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_timeout +# TYPE sacct_partition_jobs_total_six_months_timeout gauge +sacct_partition_jobs_total_six_months_timeout{partition="hpc",start_date="2025-08-23"} 15.0 +sacct_partition_jobs_total_six_months_timeout{partition="htc",start_date="2025-08-23"} 8.0 +# HELP sacct_partition_jobs_total_six_months_cancelled Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_cancelled +# TYPE sacct_partition_jobs_total_six_months_cancelled gauge +sacct_partition_jobs_total_six_months_cancelled{partition="htc",start_date="2025-08-23"} 2.0 +sacct_partition_jobs_total_six_months_cancelled{partition="gpu",start_date="2025-08-23"} 1.0 +# HELP sacct_partition_jobs_total_six_months_node_failed Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_node_failed +# TYPE sacct_partition_jobs_total_six_months_node_failed gauge +sacct_partition_jobs_total_six_months_node_failed{partition="dynamic",start_date="2025-08-23"} 1.0 +# HELP sacct_partition_jobs_total_six_months_by_state_exit_code Slurm partition 6-month rolling job metric by state and exit code +# TYPE sacct_partition_jobs_total_six_months_by_state_exit_code gauge +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="1:0",partition="hpc",reason="General failure",start_date="2025-08-23",state="failed"} 15.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="127:0",partition="hpc",reason="Command not found",start_date="2025-08-23",state="failed"} 6.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="2:0",partition="hpc",reason="Misuse of shell built-in",start_date="2025-08-23",state="failed"} 3.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="137:0",partition="hpc",reason="SIGKILL - Force killed",start_date="2025-08-23",state="failed"} 3.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="143:0",partition="hpc",reason="SIGTERM - Terminated",start_date="2025-08-23",state="failed"} 1.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="42:0",partition="hpc",reason="Other",start_date="2025-08-23",state="failed"} 1.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="0:0",partition="hpc",reason="",start_date="2025-08-23",state="timeout"} 15.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="1:0",partition="htc",reason="General failure",start_date="2025-08-23",state="failed"} 12.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="127:0",partition="htc",reason="Command not found",start_date="2025-08-23",state="failed"} 5.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="2:0",partition="htc",reason="Misuse of shell built-in",start_date="2025-08-23",state="failed"} 1.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="137:0",partition="htc",reason="SIGKILL - Force killed",start_date="2025-08-23",state="failed"} 1.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="143:0",partition="htc",reason="SIGTERM - Terminated",start_date="2025-08-23",state="failed"} 2.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="255:0",partition="htc",reason="Other",start_date="2025-08-23",state="failed"} 1.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="0:0",partition="htc",reason="",start_date="2025-08-23",state="timeout"} 8.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="0:0",partition="htc",reason="",start_date="2025-08-23",state="cancelled"} 2.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="1:0",partition="dynamic",reason="General failure",start_date="2025-08-23",state="node_failed"} 1.0 +sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="0:0",partition="gpu",reason="",start_date="2025-08-23",state="cancelled"} 1.0 +# HELP sacct_jobs_total_one_week_submitted Slurm 1-week rolling job metric: submitted +# TYPE sacct_jobs_total_one_week_submitted gauge +sacct_jobs_total_one_week_submitted{start_date="2026-02-12"} 38.0 +# HELP sacct_jobs_total_one_week_completed Slurm 1-week rolling job metric: completed +# TYPE sacct_jobs_total_one_week_completed gauge +sacct_jobs_total_one_week_completed{start_date="2026-02-12"} 30.0 +# HELP sacct_jobs_total_one_week_failed Slurm 1-week rolling job metric: failed +# TYPE sacct_jobs_total_one_week_failed gauge +sacct_jobs_total_one_week_failed{start_date="2026-02-12"} 8.0 +# HELP sacct_jobs_total_one_week_by_state Slurm 1-week rolling job metric by state +# TYPE sacct_jobs_total_one_week_by_state gauge +sacct_jobs_total_one_week_by_state{start_date="2026-02-12",state="completed"} 30.0 +sacct_jobs_total_one_week_by_state{start_date="2026-02-12",state="failed"} 8.0 +# HELP sacct_jobs_total_one_week_by_state_exit_code Slurm 1-week rolling job metric by state and exit code +# TYPE sacct_jobs_total_one_week_by_state_exit_code gauge +sacct_jobs_total_one_week_by_state_exit_code{exit_code="1:0",reason="General failure",start_date="2026-02-12",state="failed"} 3.0 +sacct_jobs_total_one_week_by_state_exit_code{exit_code="42:0",reason="Other",start_date="2026-02-12",state="failed"} 1.0 +sacct_jobs_total_one_week_by_state_exit_code{exit_code="127:0",reason="Command not found",start_date="2026-02-12",state="failed"} 3.0 +sacct_jobs_total_one_week_by_state_exit_code{exit_code="255:0",reason="Other",start_date="2026-02-12",state="failed"} 1.0 +# HELP sacct_partition_jobs_total_one_week_submitted Slurm partition 1-week rolling job metric: sacct_partition_jobs_total_one_week_submitted +# TYPE sacct_partition_jobs_total_one_week_submitted gauge +sacct_partition_jobs_total_one_week_submitted{partition="hpc",start_date="2026-02-12"} 28.0 +sacct_partition_jobs_total_one_week_submitted{partition="htc",start_date="2026-02-12"} 10.0 +# HELP sacct_partition_jobs_total_one_week_completed Slurm partition 1-week rolling job metric: sacct_partition_jobs_total_one_week_completed +# TYPE sacct_partition_jobs_total_one_week_completed gauge +sacct_partition_jobs_total_one_week_completed{partition="hpc",start_date="2026-02-12"} 23.0 +sacct_partition_jobs_total_one_week_completed{partition="htc",start_date="2026-02-12"} 7.0 +# HELP sacct_partition_jobs_total_one_week_failed Slurm partition 1-week rolling job metric: sacct_partition_jobs_total_one_week_failed +# TYPE sacct_partition_jobs_total_one_week_failed gauge +sacct_partition_jobs_total_one_week_failed{partition="hpc",start_date="2026-02-12"} 5.0 +sacct_partition_jobs_total_one_week_failed{partition="htc",start_date="2026-02-12"} 3.0 +# HELP sacct_partition_jobs_total_one_week_by_state_exit_code Slurm partition 1-week rolling job metric by state and exit code +# TYPE sacct_partition_jobs_total_one_week_by_state_exit_code gauge +sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="1:0",partition="hpc",reason="General failure",start_date="2026-02-12",state="failed"} 2.0 +sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="42:0",partition="hpc",reason="Other",start_date="2026-02-12",state="failed"} 1.0 +sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="127:0",partition="hpc",reason="Command not found",start_date="2026-02-12",state="failed"} 2.0 +sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="127:0",partition="htc",reason="Command not found",start_date="2026-02-12",state="failed"} 1.0 +sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="1:0",partition="htc",reason="General failure",start_date="2026-02-12",state="failed"} 1.0 +sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="255:0",partition="htc",reason="Other",start_date="2026-02-12",state="failed"} 1.0 +# HELP sacct_jobs_total_one_month_submitted Slurm 30-day rolling job metric: submitted +# TYPE sacct_jobs_total_one_month_submitted gauge +sacct_jobs_total_one_month_submitted{start_date="2026-01-20"} 301.0 +# HELP sacct_jobs_total_one_month_completed Slurm 30-day rolling job metric: completed +# TYPE sacct_jobs_total_one_month_completed gauge +sacct_jobs_total_one_month_completed{start_date="2026-01-20"} 223.0 +# HELP sacct_jobs_total_one_month_failed Slurm 30-day rolling job metric: failed +# TYPE sacct_jobs_total_one_month_failed gauge +sacct_jobs_total_one_month_failed{start_date="2026-01-20"} 51.0 +# HELP sacct_jobs_total_one_month_timeout Slurm 30-day rolling job metric: timeout +# TYPE sacct_jobs_total_one_month_timeout gauge +sacct_jobs_total_one_month_timeout{start_date="2026-01-20"} 23.0 +# HELP sacct_jobs_total_one_month_node_failed Slurm 30-day rolling job metric: node_failed +# TYPE sacct_jobs_total_one_month_node_failed gauge +sacct_jobs_total_one_month_node_failed{start_date="2026-01-20"} 1.0 +# HELP sacct_jobs_total_one_month_cancelled Slurm 30-day rolling job metric: cancelled +# TYPE sacct_jobs_total_one_month_cancelled gauge +sacct_jobs_total_one_month_cancelled{start_date="2026-01-20"} 3.0 +# HELP sacct_jobs_total_one_month_by_state Slurm 30-day rolling job metric by state +# TYPE sacct_jobs_total_one_month_by_state gauge +sacct_jobs_total_one_month_by_state{start_date="2026-01-20",state="completed"} 223.0 +sacct_jobs_total_one_month_by_state{start_date="2026-01-20",state="failed"} 51.0 +sacct_jobs_total_one_month_by_state{start_date="2026-01-20",state="timeout"} 23.0 +sacct_jobs_total_one_month_by_state{start_date="2026-01-20",state="node_failed"} 1.0 +sacct_jobs_total_one_month_by_state{start_date="2026-01-20",state="cancelled"} 3.0 +# HELP sacct_jobs_total_one_month_by_state_exit_code Slurm 30-day rolling job metric by state and exit code +# TYPE sacct_jobs_total_one_month_by_state_exit_code gauge +sacct_jobs_total_one_month_by_state_exit_code{exit_code="1:0",reason="General failure",start_date="2026-01-20",state="failed"} 27.0 +sacct_jobs_total_one_month_by_state_exit_code{exit_code="127:0",reason="Command not found",start_date="2026-01-20",state="failed"} 11.0 +sacct_jobs_total_one_month_by_state_exit_code{exit_code="2:0",reason="Misuse of shell built-in",start_date="2026-01-20",state="failed"} 4.0 +sacct_jobs_total_one_month_by_state_exit_code{exit_code="137:0",reason="SIGKILL - Force killed",start_date="2026-01-20",state="failed"} 4.0 +sacct_jobs_total_one_month_by_state_exit_code{exit_code="143:0",reason="SIGTERM - Terminated",start_date="2026-01-20",state="failed"} 3.0 +sacct_jobs_total_one_month_by_state_exit_code{exit_code="42:0",reason="Other",start_date="2026-01-20",state="failed"} 1.0 +sacct_jobs_total_one_month_by_state_exit_code{exit_code="255:0",reason="Other",start_date="2026-01-20",state="failed"} 1.0 +sacct_jobs_total_one_month_by_state_exit_code{exit_code="0:0",reason="",start_date="2026-01-20",state="timeout"} 23.0 +sacct_jobs_total_one_month_by_state_exit_code{exit_code="1:0",reason="General failure",start_date="2026-01-20",state="node_failed"} 1.0 +sacct_jobs_total_one_month_by_state_exit_code{exit_code="0:0",reason="",start_date="2026-01-20",state="cancelled"} 3.0 +# HELP sacct_partition_jobs_total_one_month_submitted Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_submitted +# TYPE sacct_partition_jobs_total_one_month_submitted gauge +sacct_partition_jobs_total_one_month_submitted{partition="hpc",start_date="2026-01-20"} 182.0 +sacct_partition_jobs_total_one_month_submitted{partition="htc",start_date="2026-01-20"} 112.0 +sacct_partition_jobs_total_one_month_submitted{partition="dynamic",start_date="2026-01-20"} 6.0 +sacct_partition_jobs_total_one_month_submitted{partition="gpu",start_date="2026-01-20"} 1.0 +# HELP sacct_partition_jobs_total_one_month_completed Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_completed +# TYPE sacct_partition_jobs_total_one_month_completed gauge +sacct_partition_jobs_total_one_month_completed{partition="hpc",start_date="2026-01-20"} 138.0 +sacct_partition_jobs_total_one_month_completed{partition="htc",start_date="2026-01-20"} 80.0 +sacct_partition_jobs_total_one_month_completed{partition="dynamic",start_date="2026-01-20"} 5.0 +# HELP sacct_partition_jobs_total_one_month_failed Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_failed +# TYPE sacct_partition_jobs_total_one_month_failed gauge +sacct_partition_jobs_total_one_month_failed{partition="hpc",start_date="2026-01-20"} 29.0 +sacct_partition_jobs_total_one_month_failed{partition="htc",start_date="2026-01-20"} 22.0 +# HELP sacct_partition_jobs_total_one_month_timeout Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_timeout +# TYPE sacct_partition_jobs_total_one_month_timeout gauge +sacct_partition_jobs_total_one_month_timeout{partition="hpc",start_date="2026-01-20"} 15.0 +sacct_partition_jobs_total_one_month_timeout{partition="htc",start_date="2026-01-20"} 8.0 +# HELP sacct_partition_jobs_total_one_month_cancelled Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_cancelled +# TYPE sacct_partition_jobs_total_one_month_cancelled gauge +sacct_partition_jobs_total_one_month_cancelled{partition="htc",start_date="2026-01-20"} 2.0 +sacct_partition_jobs_total_one_month_cancelled{partition="gpu",start_date="2026-01-20"} 1.0 +# HELP sacct_partition_jobs_total_one_month_node_failed Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_node_failed +# TYPE sacct_partition_jobs_total_one_month_node_failed gauge +sacct_partition_jobs_total_one_month_node_failed{partition="dynamic",start_date="2026-01-20"} 1.0 +# HELP sacct_partition_jobs_total_one_month_by_state_exit_code Slurm partition 30-day rolling job metric by state and exit code +# TYPE sacct_partition_jobs_total_one_month_by_state_exit_code gauge +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="1:0",partition="hpc",reason="General failure",start_date="2026-01-20",state="failed"} 15.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="127:0",partition="hpc",reason="Command not found",start_date="2026-01-20",state="failed"} 6.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="2:0",partition="hpc",reason="Misuse of shell built-in",start_date="2026-01-20",state="failed"} 3.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="137:0",partition="hpc",reason="SIGKILL - Force killed",start_date="2026-01-20",state="failed"} 3.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="143:0",partition="hpc",reason="SIGTERM - Terminated",start_date="2026-01-20",state="failed"} 1.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="42:0",partition="hpc",reason="Other",start_date="2026-01-20",state="failed"} 1.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="0:0",partition="hpc",reason="",start_date="2026-01-20",state="timeout"} 15.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="1:0",partition="htc",reason="General failure",start_date="2026-01-20",state="failed"} 12.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="127:0",partition="htc",reason="Command not found",start_date="2026-01-20",state="failed"} 5.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="2:0",partition="htc",reason="Misuse of shell built-in",start_date="2026-01-20",state="failed"} 1.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="137:0",partition="htc",reason="SIGKILL - Force killed",start_date="2026-01-20",state="failed"} 1.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="143:0",partition="htc",reason="SIGTERM - Terminated",start_date="2026-01-20",state="failed"} 2.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="255:0",partition="htc",reason="Other",start_date="2026-01-20",state="failed"} 1.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="0:0",partition="htc",reason="",start_date="2026-01-20",state="timeout"} 8.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="0:0",partition="htc",reason="",start_date="2026-01-20",state="cancelled"} 2.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="1:0",partition="dynamic",reason="General failure",start_date="2026-01-20",state="node_failed"} 1.0 +sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="0:0",partition="gpu",reason="",start_date="2026-01-20",state="cancelled"} 1.0 +# HELP azslurm_cluster_info Static cluster information from azslurm +# TYPE azslurm_cluster_info gauge +azslurm_cluster_info{cluster_name="azslurm-exporter",region="westeurope",resource_group="azcyclecloudwesteu-rg",subscription_id="cbbe2034-c78b-4e9b-89b4-8b78530247e5"} 1.0 +# HELP azslurm_partition_info Static partition information from azslurm with VM size and node details +# TYPE azslurm_partition_info gauge +azslurm_partition_info{available_azure_quota="4",node_list="dyn1-[1-10],dyn2-[1-10],g1",partition="dynamic",vm_size="Standard_F2s_v2"} 4.0 +azslurm_partition_info{available_azure_quota="4",node_list="dyn1-[1-10],dyn2-[1-10],g1",partition="dynamic",vm_size="Standard_D2ds_v5"} 4.0 +azslurm_partition_info{available_azure_quota="0",node_list="dyn1-[1-10],dyn2-[1-10],g1",partition="dynamic",vm_size="Standard_NC80adis_H100_v5"} 0.0 +azslurm_partition_info{available_azure_quota="1",node_list="azslurm-exporter-gpu-1",partition="gpu",vm_size="Standard_NC80adis_H100_v5"} 1.0 +azslurm_partition_info{available_azure_quota="4",node_list="azslurm-exporter-hpc-[1-16]",partition="hpc",vm_size="Standard_F2s_v2"} 0.0 +azslurm_partition_info{available_azure_quota="4",node_list="azslurm-exporter-htc-[1-50]",partition="htc",vm_size="Standard_F2s_v2"} 4.0 +# HELP slurm_exporter_collect_duration_seconds Time spent collecting Slurm metrics +# TYPE slurm_exporter_collect_duration_seconds gauge +slurm_exporter_collect_duration_seconds 0.69551682472229 +# HELP slurm_exporter_last_collect_timestamp_seconds Timestamp of last successful metric collection +# TYPE slurm_exporter_last_collect_timestamp_seconds gauge +slurm_exporter_last_collect_timestamp_seconds 1.771529638582487e+09 \ No newline at end of file diff --git a/azure-slurm-exporter/current_dashboard_metrics.txt b/azure-slurm-exporter/current_dashboard_metrics.txt new file mode 100644 index 00000000..d46f2f06 --- /dev/null +++ b/azure-slurm-exporter/current_dashboard_metrics.txt @@ -0,0 +1,11 @@ +azslurm_cluster_info +scontrol_partition_nodes_{state} +azslurm_partition_info +squeue_partition_jobs_{state} +squeue_job_nodes_allocated +sacct_jobs_total_one_month_by_state +sacct_jobs_total_six_months_by_state +sacct_jobs_total_one_week_by_state +sacct_partition_jobs_total_six_months_by_state_exit_code +sacct_partition_jobs_total_one_month_by_state_exit_code +sacct_partition_jobs_total_one_week_by_state_exit_code From 84e00cd79177bbda0f6f6c7cb21bccb8e6de6d0d Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Mon, 2 Mar 2026 15:18:18 -0500 Subject: [PATCH 02/22] add azslurm exporter implementation --- azure-slurm-exporter/exporter.py | 278 +++++++++++++++++++++++++++++++ azure-slurm-exporter/sacct.py | 137 +++++++++++++++ azure-slurm-exporter/sinfo.py | 144 ++++++++++++++++ azure-slurm-exporter/squeue.py | 144 ++++++++++++++++ azure-slurm-exporter/util.py | 13 ++ 5 files changed, 716 insertions(+) create mode 100644 azure-slurm-exporter/exporter.py create mode 100644 azure-slurm-exporter/sacct.py create mode 100644 azure-slurm-exporter/sinfo.py create mode 100644 azure-slurm-exporter/squeue.py create mode 100644 azure-slurm-exporter/util.py diff --git a/azure-slurm-exporter/exporter.py b/azure-slurm-exporter/exporter.py new file mode 100644 index 00000000..4e96501a --- /dev/null +++ b/azure-slurm-exporter/exporter.py @@ -0,0 +1,278 @@ +import asyncio +import logging +import signal +import sys +import time +from prometheus_client import CollectorRegistry, Metric, Counter, Gauge, Summary +from abc import ABC, abstractmethod +from functools import partial +from collections import namedtuple +from aiohttp import web +from prometheus_client.aiohttp import make_aiohttp_handler +from typing import Iterator, List, Union + +log = logging.getLogger(__name__) +CommandResult = namedtuple("CommandResult", ["returncode", "stdout", "stderr"]) + +class NoCollectorsFoundException(Exception): + pass + +class HTTPServerFailedException(Exception): + pass + +class BaseCollector(ABC): + @abstractmethod + async def start(self) -> None: + """ + Begin collecting metrics asynchronously. + + This method initializes the metric collection process and runs it at regular + intervals as defined by the configured downstream interval. The collection + runs asynchronously without blocking the event loop. + """ + ... + + @abstractmethod + def export_metrics(self) -> List[Union[Gauge,Counter,Summary]]: + """ + Return metrics in Prometheus-compatible format. + + Returns: + list: A list of metric objects in Prometheus-compatible format, + ready for exposition to monitoring backends. + """ + ... + + async def run_command(self, *args, timeout=120,) -> CommandResult: + """ + Execute a command asynchronously and capture its output. + + This method runs an external command in a subprocess, capturing both stdout + and stderr, with support for timeout handling. + + Args: + *args: Command and arguments to execute. Each argument is converted to string. + timeout (int, optional): Maximum time in seconds to wait for command completion. + Defaults to 120 seconds. + + Returns: + CommandResult: An object containing: + - returncode (int): The exit code of the process + - stdout (bytes): The standard output of the command + - stderr (bytes): The standard error output of the command + + Raises: + RuntimeError: If the command exceeds the specified timeout duration. + The process is terminated before raising this exception. + """ + start = time.monotonic() + cmd_str = " ".join(str(a) for a in args) + proc = await asyncio.create_subprocess_exec(*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + try: + async with asyncio.timeout(timeout): + stdout, stderr = await proc.communicate() + except TimeoutError: + proc.kill() + await proc.wait() + log.error("Command: %s timed out after %d seconds and was killed", cmd_str, timeout) + raise RuntimeError("Process timed out and was killed") + elapsed = time.monotonic() - start + log.debug("Command: %s, Exit code: %d, Time Elapsed: %f", cmd_str, proc.returncode, elapsed) + if stderr: + log.warning("stderr:\n%s", stderr.decode()) + return CommandResult(returncode=proc.returncode, stdout=stdout, stderr=stderr) + + def launch_task(self, func, interval) -> None: + """ + Launch an asynchronous task that executes a function at regular intervals. + + Args: + func: A callable function to be executed repeatedly. + interval: The time interval (in seconds) between successive executions of the function. + """ + + asyncio.create_task(self.__schedule(func, interval)) + + async def __schedule(self, func, interval: int) -> None: + """ + Schedule a function to be executed periodically at specified intervals. + + Args: + func: A callable function to be scheduled for execution. + interval (int): The time interval in seconds between function executions. + + Notes: + - This is an async method that executes the function once immediately, + then schedules subsequent executions using asyncio's call_later. + - The function is executed in a non-blocking manner using asyncio. + """ + if callable(func): + reponse = await func() + loop = asyncio.get_running_loop() + loop.call_later(interval, partial(self.launch_task, func, interval)) + else: + log.error(f"func {func.__name__} is not callable") + +class AzslurmCollector: + def __init__(self): + self.collectors = [] + + def initialize_collectors(self) -> None: + """ + Initialize and start all collectors concurrently. + + Attempts to initialize three types of collectors in sequence: + - Squeue: Collects job queue information + - Sacct: Collects job accounting data + - Sinfo: Collects node/partition information + + Each collector is independently initialized and added to the collectors list + if available. If a collector is not available, a warning is logged and the + initialization continues with the next collector. + + Raises: + NoCollectorsFoundException: If no collectors were successfully initialized. + """ + try: + from squeue import Squeue, SqueueNotAvailException + squeue = Squeue() + squeue.initialize() + except SqueueNotAvailException: + log.warning("squeue is not available, disabling squeue metrics") + else: + self.collectors.append(squeue) + + try: + from sacct import Sacct, SacctNotAvailException + sacct = Sacct() + sacct.initialize() + except SacctNotAvailException: + log.warning("Accounting is disabled, disabling sacct metrics") + else: + self.collectors.append(sacct) + + try: + from sinfo import Sinfo, SinfoNotAvailException + sinfo = Sinfo() + sinfo.initialize() + except SinfoNotAvailException: + log.warning("sinfo is not available, disabling sinfo metrics") + else: + self.collectors.append(sinfo) + + if not self.collectors: + log.error("No collectors intialized") + raise NoCollectorsFoundException + for collector in self.collectors: + collector.start() + + def export_metrics(self) -> List[Union[Gauge,Counter,Summary]]: + """ + Collect and aggregate metrics from all configured collectors. + + Iterates through ``self.collectors``, calls each collector's + ``export_metrics()`` method, and combines all returned metric items + into a single list. + + Returns: + list: A flattened list containing metric objects from every collector. + """ + + metrics = [] + for collector in self.collectors: + metrics.extend(collector.export_metrics()) + return metrics + + def collect(self) -> Iterator[Metric]: + """ + Collect and yield Prometheus metrics. + + This method retrieves exported metrics and iterates through them, + yielding collected metric samples for each metric object. + + Yields: + Metric samples from each exported metric's collect() method. + """ + + metrics = self.export_metrics() + for metric in metrics: + yield from metric.collect() + + async def start_http_server(self, host, port) -> web.AppRunner: + """ + Start an HTTP server for Prometheus metrics collection. + + This asynchronous method initializes and starts an aiohttp web server that + exposes Prometheus metrics on the /metrics endpoint. The server listens on + the specified host and port. + Args: + host (str): The host address to bind the server to (e.g., '0.0.0.0'). + port (int): The port number to listen on (e.g., 9101). + + Returns: + web.AppRunner: The runner object for the started HTTP server, which can + be used to manage the server lifecycle. + + Raises: + HTTPServerFailedException: Raised if the server fails to bind to the + specified host and port (OSError) or if any + unexpected error occurs during server startup. + + Note: + The server registers the current instance as a Prometheus collector + to expose metrics. After calling this method, metrics will be available + at at http://{host}:{port}/metrics. + """ + try: + registry = CollectorRegistry() + registry.register(self) + app = web.Application() + app.router.add_get("/metrics", make_aiohttp_handler(registry)) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + log.info("Prometheus exporter serving on http://%s:%d/metrics", host, port) + return runner + except OSError as e: + log.error("Failed to bind to %s:%d - %s", host, port, e) + raise HTTPServerFailedException + except Exception as e: + log.error("Unexpected error starting HTTP server: %s", e) + raise HTTPServerFailedException + + +async def main(): + #TODO: file based logging + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s %(name)s %(levelname)s %(message)s" + ) + loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop_event.set) + + collector = AzslurmCollector() + + try: + collector.initialize_collectors() + except NoCollectorsFoundException: + sys.exit(1) + + try: + runner = await collector.start_http_server(host="0.0.0.0", port=9101) + except HTTPServerFailedException: + sys.exit(1) + + # Keep running until interrupted + try: + await stop_event.wait() + except asyncio.CancelledError: + pass + finally: + await runner.cleanup() + +if __name__=="__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/azure-slurm-exporter/sacct.py b/azure-slurm-exporter/sacct.py new file mode 100644 index 00000000..db945e22 --- /dev/null +++ b/azure-slurm-exporter/sacct.py @@ -0,0 +1,137 @@ +from exporter import BaseCollector +from collections import namedtuple +from prometheus_client import Counter, disable_created_metrics +from datetime import datetime, timedelta +import logging +import util as util +from typing import List +log = logging.getLogger(__name__) + +class SacctNotAvailException(Exception): + pass + +class Sacct(BaseCollector): + + SLURM_EXIT_CODE_MAPPING = { + "0:0": "", + "1:0": "General failure", + "2:0": "Misuse of shell built-in", + "125:0": "Slurm Out of Memory Error", + "126:0": "Command invoked cannot execute", + "127:0": "Command not found", + "128:0": "Invalid argument to exit", + "129:0": "SIGHUP", + "130:0": "SIGINT - Ctrl+C", + "131:0": "SIGQUIT", + "134:0": "SIGABRT", + "137:0": "SIGKILL - Force killed", + "139:0": "SIGSEGV - Segfault", + "141:0": "SIGPIPE", + "143:0": "SIGTERM - Terminated", + "152:0": "SIGXCPU - CPU limit", + "153:0": "SIGXFSZ - File size limit", + } + + def __init__(self, binary_path="/usr/bin/sacct", interval=300, timeout=120): + self.binary_path = binary_path + self.interval = interval + self.timeout = timeout + self.sacct_terminal_jobs= Counter("sacct_terminal_jobs","Total Number of completed slurm jobs", + ["partition", "exit_code","reason","state"], registry=None) + self.default_output_fmt = "jobid,jobname,nodelist,nnodes,partition,exitcode,derivedexitcode,state,user,start,submit,end,reason" + self.sacct_output = namedtuple("sacct_output", self.default_output_fmt) + self.terminal_states = "completed,failed,cancelled,timeout,node_fail,preempted,out_of_memory,deadline,boot_fail" + self.starttime = (datetime.now() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S") + self.endtime = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + self.default_options = ["-P" ,"-n", "-o", self.default_output_fmt, "-s", self.terminal_states, "--allocations" ] + + def initialize(self) -> None: + """ + Initialize the Sacct instance by validating the binary and disabling created metrics. + + Checks if the sacct binary exists and is executable. If the binary is not found + or is not executable, logs an error and raises an exception. + + Raises: + SacctNotAvailException: If the sacct binary is not available or not executable. + """ + if not util.is_file_binary(self.binary_path): + log.error(f"{self.binary_path} is not a file or not executable") + raise SacctNotAvailException + disable_created_metrics() + + def start(self) -> None: + """ + Begin collecting metrics asynchronously. + + This method initializes the metric collection process and runs it at regular + intervals as defined by the configured downstream interval. The collection + runs asynchronously without blocking the event loop. + """ + self.launch_task(func=self.sacct_query, interval=self.interval) + + def export_metrics(self) -> List[Counter]: + """ + Return metrics in Prometheus-compatible format. + + Returns: + list: A list of metric objects in Prometheus-compatible format, + ready for exposition to monitoring backends. + """ + #TODO: DO we need to lock this? + return [self.sacct_terminal_jobs] + + def parse_output(self, stdout) -> None: + """ + Parse sacct command output and increment terminal job metrics. + + Processes the raw stdout from sacct command, splits it into lines and fields, + and updates Prometheus metrics for completed SLURM jobs with their partition, + exit code, reason, and state. + + Args: + stdout (bytes): The raw output from sacct command execution. + """ + seen={} + lines = stdout.decode().strip().splitlines() + log.debug(f"Number of jobs:{len(lines)}") + lines_iter = (line.split("|") for line in lines) + for row in map(self.sacct_output._make, lines_iter): + reason = self.SLURM_EXIT_CODE_MAPPING.get(row.exitcode, "") + self.sacct_terminal_jobs.labels( + partition=row.partition, + exit_code=row.exitcode, + reason=reason, + state=row.state).inc() + + async def sacct_query(self) -> None: + """ + Query SLURM accounting data (sacct) for jobs within a specified time window. + + This method queries sacct filtering by a time window of size `interval`, which represents + the frequency at which queries are executed. For example, if interval is 5 minutes, the + query retrieves job data from the last 5 minutes. + + The time window progresses as follows: + - The start time of the current query is set to the end time of the previous query + - The end time is set to the current moment when the query is executed + - After execution, starttime is updated to the current endtime for the next query iteration + + The method constructs and executes the sacct command with appropriate filters and handles + any exceptions that occur during query execution. Query output is parsed upon successful completion. + """ + args = [] + self.endtime = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + args.append(self.binary_path) + args.extend(self.default_options) + args.extend(["--starttime", self.starttime, "--endtime", self.endtime]) + log.debug(f"running sacct query between {self.starttime} and {self.endtime}") + try: + proc = await self.run_command(timeout=self.timeout,*args) + except Exception as e: + log.error(e) + return + self.starttime=self.endtime + self.parse_output(proc.stdout) + + diff --git a/azure-slurm-exporter/sinfo.py b/azure-slurm-exporter/sinfo.py new file mode 100644 index 00000000..27c58bbd --- /dev/null +++ b/azure-slurm-exporter/sinfo.py @@ -0,0 +1,144 @@ +from exporter import BaseCollector +from collections import namedtuple +from prometheus_client import Gauge +import logging +import util as util +from typing import List + +log = logging.getLogger(__name__) + +class SinfoNotAvailException(Exception): + pass + +class Sinfo(BaseCollector): + + # Suffix meanings from sinfo man page + STATE_SUFFIXES = { + "*": "not_responding", + "~": "powered_off", + "#": "powering_up", + "!": "pending_power_down", + "%": "powering_down", + "$": "maintenance_reservation", + "@": "pending_reboot", + "^": "reboot_issued", + "-": "planned_backfill", + } + + def __init__(self, binary_path="/usr/bin/sinfo", interval=30, timeout=15): + self.binary_path = binary_path + self.interval = interval + self.timeout = timeout + self.cached_output = {"sinfo_query":[]} + self.default_output_fmt = f"%N|%D|%R|%E|%T" + self.default_output_headers = "nodelist,nodes,partition,reason,state" + self.sinfo_output = namedtuple("sinfo_output", self.default_output_headers) + self.default_options = ["-h", "-o", self.default_output_fmt] + + def initialize(self) -> None: + """ + Initialize the Sinfo object by validating the binary path. + + Checks if the binary file at self.binary_path exists and is executable. + + Raises: + SinfoNotAvailException: If the binary path is not a valid file or not executable. + """ + if not util.is_file_binary(self.binary_path): + log.error(f"{self.binary_path} is not a file or not executable") + raise SinfoNotAvailException + + def start(self) -> None: + """ + Begin collecting metrics asynchronously. + + This method initializes the metric collection process and runs it at regular + intervals as defined by the configured downstream interval. The collection + runs asynchronously without blocking the event loop. + """ + self.launch_task(func=self.sinfo_query, interval=self.interval) + + def export_metrics(self) -> List[Gauge]: + """ + Return metrics in Prometheus-compatible format. + + Returns: + list: A list of metric objects in Prometheus-compatible format, + ready for exposition to monitoring backends. + """ + return self.cached_output["sinfo_query"] + + def normalize_state(self, state) -> str: + """ + Normalize SLURM node state by mapping state suffixes to their base states. + + This method extracts the last character of the input state string and checks if it + matches a known suffix in STATE_SUFFIXES. If a matching suffix is found, it returns + the mapped state value; otherwise, it returns the original state string unchanged. + + Args: + state (str): The SLURM node state string that may contain a suffix character. + + Returns: + str: The normalized state. If the last character is a known suffix, returns the + mapped state value from STATE_SUFFIXES. Otherwise, returns the original + state string. + + """ + suffix = state[-1] + if suffix in self.STATE_SUFFIXES: + return self.STATE_SUFFIXES[suffix] + else: + return state + + def parse_output(self, stdout) -> List[Gauge]: + """ + Parse the output from the sinfo command and create a Gauge metric. + + This method processes the stdout from an sinfo command execution, parses each line + into structured data, normalizes the node state, and creates a Prometheus Gauge metric + that tracks the number of nodes in each state per partition. + + Args: + stdout (bytes): The raw output from the sinfo command as bytes. + + Returns: + list: A list containing a single Gauge metric object with labels for node_list, + partition, state, and reason, where the metric value represents the number + of nodes in that particular state. + """ + sinfo_partitions_nodes_state = Gauge( + f"sinfo_partition_nodes_state", + f"Number of nodes in a state per partition", + labelnames=['node_list','partition','state', 'reason'], registry=None + ) + + lines = stdout.decode().strip().splitlines() + lines_iter = (line.split("|") for line in lines) + for row in map(self.sinfo_output._make, lines_iter): + state = self.normalize_state(row.state) + sinfo_partitions_nodes_state.labels(node_list=row.nodelist, + partition=row.partition, + state=state, + reason=row.reason).set(float(row.nodes)) + return [sinfo_partitions_nodes_state] + + + async def sinfo_query(self) -> None: + """ + Execute sinfo command asynchronously and cache the results. + + This method runs the sinfo command with configured default options and a specified timeout. + The command output is parsed and stored in the cached_output dictionary for later retrieval. + """ + args = [self.binary_path] + args.extend(self.default_options) + try: + proc = await self.run_command(timeout=self.timeout,*args) + except Exception as e: + log.error(e) + return + output = self.parse_output(proc.stdout) + #TODO: DO we need to lock this? + self.cached_output["sinfo_query"] = output + diff --git a/azure-slurm-exporter/squeue.py b/azure-slurm-exporter/squeue.py new file mode 100644 index 00000000..8bfb5c42 --- /dev/null +++ b/azure-slurm-exporter/squeue.py @@ -0,0 +1,144 @@ +from exporter import BaseCollector +from collections import namedtuple +from prometheus_client import Gauge +from dataclasses import dataclass, field +import logging +import util +from typing import List +# @dataclass +# class SqueueMetrics: +# squeue_partition_jobs_state: GaugeMetricFamily = GaugeMetricFamily( +# f"squeue_partition_jobs_state", +# f"Number of jobs in a state per partition", +# labels=['partition','state'], +# ) + +# squeue_job_nodes_allocated: GaugeMetricFamily = GaugeMetricFamily( +# "squeue_job_nodes_allocated", +# "Number of nodes allocated to a running job", +# labels=["job_id", "job_name", "partition", "state"], +# ) + +# def add(self, label: list, value): + + + +log = logging.getLogger(__name__) + +class SqueueNotAvailException(Exception): + pass + +class Squeue(BaseCollector): + + def __init__(self, binary_path="/usr/bin/squeue", interval=60, timeout=30): + self.binary_path = binary_path + self.interval = interval + self.timeout = timeout + self.cached_output = {"squeue_metrics":[]} + self.default_output_fmt = f"%i|%j|%D|%N|%P|%T|%V|%u" + self.default_output_headers = "jobid,name,nodes,nodelist,partition,state,submit_time,user" + self.squeue_output = namedtuple("squeue_output", self.default_output_headers) + self.default_options = ["-h", "-o", self.default_output_fmt] + + def initialize(self) -> None: + """ + Initialize the Squeue instance and validate the binary executable. + + Raises: + SqueueNotAvailException: If the binary at self.binary_path is not a file or is not executable. + """ + if not util.is_file_binary(self.binary_path): + log.error(f"{self.binary_path} is not a file or not executable") + raise SqueueNotAvailException + + def start(self) -> None: + """ + Begin collecting metrics asynchronously. + + This method initializes the metric collection process and runs it at regular + intervals as defined by the configured downstream interval. The collection + runs asynchronously without blocking the event loop. + """ + self.launch_task(func=self.squeue_query, interval=self.interval) + + def export_metrics(self) -> List[Gauge]: + """ + Return metrics in Prometheus-compatible format. + + Returns: + list: A list of metric objects in Prometheus-compatible format, + ready for exposition to monitoring backends. + """ + return self.cached_output["squeue_metrics"] + + def parse_output(self,stdout) -> List[Gauge]: + """ + Parse squeue command output and create Prometheus metrics. + + This method processes the stdout from an squeue command and generates two Prometheus + Gauge metrics: + + 1. squeue_partition_jobs_state: Tracks the number of jobs in each state per partition + - Labels: partition, state + + 2. squeue_job_nodes_allocated: Tracks the number of nodes allocated to running jobs + - Labels: job_id, job_name, partition, state + - Only populated for jobs in "running" state + + Args: + stdout (bytes): The raw output from the squeue command, encoded as bytes. + + Returns: + list: A list containing two Prometheus Gauge objects: + - squeue_partition_jobs_state: Job counts aggregated by partition and state + - squeue_job_nodes_allocated: Node allocations indexed by job details + """ + squeue_partition_jobs_state = Gauge( + f"squeue_partition_jobs_state", + f"Number of jobs in a state per partition", + labelnames=['partition','state'], registry=None + ) + + squeue_job_nodes_allocated = Gauge( + "squeue_job_nodes_allocated", + "Number of nodes allocated to a running job", + labelnames=["job_id", "job_name", "partition", "state"], registry=None + ) + # number of jobs per state,partition key + counts = {} + lines = stdout.decode().strip().splitlines() + lines_iter = (line.split("|") for line in lines) + + for row in map(self.squeue_output._make, lines_iter): + if row.state.lower() == "running": + squeue_job_nodes_allocated.labels(job_id=row.jobid, + job_name=row.name, + partition=row.partition, + state=row.state).set(float(row.nodes)) + key = (row.partition, row.state.lower()) + counts[key] = counts.get(key, 0) + 1 + + for (partition, state), count in counts.items(): + squeue_partition_jobs_state.labels(partition=partition, + state=state).set(count) + + return [squeue_partition_jobs_state, squeue_job_nodes_allocated] + + async def squeue_query(self) -> None: + """ + Execute an squeue query asynchronously and cache the results. + + This method runs the squeue command with default options and parses the output. + The parsed metrics are stored in the cached_output dictionary under the key + 'squeue_metrics' for later retrieval. + """ + args = [self.binary_path] + args.extend(self.default_options) + try: + proc = await self.run_command(timeout=self.timeout,*args) + except Exception as e: + log.error(e) + return + output = self.parse_output(proc.stdout) + #TODO: DO we need to lock this? + self.cached_output["squeue_metrics"] = output diff --git a/azure-slurm-exporter/util.py b/azure-slurm-exporter/util.py new file mode 100644 index 00000000..ea8c6c68 --- /dev/null +++ b/azure-slurm-exporter/util.py @@ -0,0 +1,13 @@ +import os + +def is_file_binary(binary) -> bool: + """ + Check if a file exists and is executable. + + Args: + binary (str): The file path to check. + + Returns: + bool: True if the file exists and is executable, False otherwise. + """ + return os.path.isfile(binary) and os.access(binary, os.X_OK) \ No newline at end of file From 12f1554ad5eef5943c59855b1c1c8fb7cdd1157d Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Tue, 3 Mar 2026 13:20:59 -0500 Subject: [PATCH 03/22] add azslurm and jetpack collectors and adjust doc strings --- azure-slurm-exporter/azslurm.py | 108 ++++++++++++++++++++++++++++ azure-slurm-exporter/exporter.py | 118 ++++++++----------------------- azure-slurm-exporter/jetpack.py | 70 ++++++++++++++++++ azure-slurm-exporter/sacct.py | 40 ++--------- azure-slurm-exporter/sinfo.py | 54 +++----------- azure-slurm-exporter/squeue.py | 40 +++-------- azure-slurm-exporter/util.py | 6 -- 7 files changed, 232 insertions(+), 204 deletions(-) create mode 100644 azure-slurm-exporter/azslurm.py create mode 100644 azure-slurm-exporter/jetpack.py diff --git a/azure-slurm-exporter/azslurm.py b/azure-slurm-exporter/azslurm.py new file mode 100644 index 00000000..52aea406 --- /dev/null +++ b/azure-slurm-exporter/azslurm.py @@ -0,0 +1,108 @@ +from exporter import BaseCollector +from collections import namedtuple +from prometheus_client import Gauge +import json +import logging +import util as util +import re +from typing import List +log = logging.getLogger(__name__) + +class AzslurmNotAvailException(Exception): + pass + +class Azslurm(BaseCollector): + + def __init__(self, binary_path="/root/bin/azslurm", interval=300, timeout=120): + self.binary_path = binary_path + self.interval = interval + self.timeout = timeout + self.partition_output = namedtuple("partition_output", ["partition", "node_list"]) + self.limits_default_fmt = ["nodearray","vm_size","available_count","family_available_count","regional_available_count"] + self.limits_output = namedtuple("limits_output",self.limits_default_fmt) + self.cached_output = {"azslurm_metrics":[]} + + def initialize(self) -> None: + """ + Initialize the Azslurm instance by validating the binary + """ + if not util.is_file_binary(self.binary_path): + log.error(f"{self.binary_path} is not a file or not executable") + raise AzslurmNotAvailException + + def start(self) -> None: + """ + Begin collecting metrics asynchronously and runs it at regular + intervals as defined by the configured downstream interval. + """ + self.launch_task(func=self.azslurm_query, interval=self.interval) + + def export_metrics(self) -> List[Gauge]: + """ + Return metrics in Prometheus-compatible format from cache. + """ + #TODO: DO we need to lock this? + return self.cached_output["azslurm_metrics"] + + def parse_output(self, partitions_stdout, limits_stdout) -> None: + """ + Parse azslurm command stdout and return prometheus gauge for partition specs + """ + azslurm_partition_info = Gauge("azslurm_partition_info", "Partition specs for cluster and available nodecount for each partitions", + labelnames=["partition", "nodelist", "vm_size", "azure_count"], + registry=None) + + nodelist_map = self._parse_partitions(partitions_stdout) + for row in self._parse_limits(limits_stdout): + nodelist = nodelist_map.get(row.nodearray, "") + azslurm_partition_info.labels(partition=row.nodearray, + nodelist=nodelist, + vm_size=row.vm_size, + azure_count=min(int(row.family_available_count), int(row.regional_available_count)) + ).set(int(row.available_count)) + return [azslurm_partition_info] + + def _parse_partitions(self, stdout) -> dict: + """ + Parse azslurm partitions output into {nodearray: nodelist} map. + """ + node_list_map = {} + partition_name = None + for line in stdout.decode().strip().splitlines(): + if line.startswith("#"): + continue + kv = dict(re.findall(r'(\w+)=(\S+)', line)) + if "PartitionName" in kv: + partition_name = kv["PartitionName"] + if "Nodename" in kv and partition_name: + node_list_map[partition_name] = kv["Nodename"] + partition_name = None + return node_list_map + + def _parse_limits(self, stdout): + """ + Parse azslurm limits JSON output into namedtuples. + """ + for entry in json.loads(stdout.decode()): + yield self.limits_output._make(entry[f] for f in self.limits_default_fmt) + + async def azslurm_query(self) -> None: + """ + Query azslurm partitions and limits command and save parsed output in prometheus metrics + format to cache + """ + args_partitions = [self.binary_path] + args_limits = [self.binary_path] + args_partitions.extend(["partitions"]) + args_limits.extend(["limits"]) + + try: + proc_partitions = await self.run_command(timeout=self.timeout,*args_partitions) + proc_limits = await self.run_command(timeout=self.timeout,*args_limits) + except Exception as e: + log.error(e) + return + + self.cached_output["azslurm_metrics"] = self.parse_output(proc_partitions.stdout, proc_limits.stdout) + + diff --git a/azure-slurm-exporter/exporter.py b/azure-slurm-exporter/exporter.py index 4e96501a..c41e4be6 100644 --- a/azure-slurm-exporter/exporter.py +++ b/azure-slurm-exporter/exporter.py @@ -24,11 +24,8 @@ class BaseCollector(ABC): @abstractmethod async def start(self) -> None: """ - Begin collecting metrics asynchronously. - - This method initializes the metric collection process and runs it at regular - intervals as defined by the configured downstream interval. The collection - runs asynchronously without blocking the event loop. + Begin collecting metrics asynchronously and runs it at regular + intervals as defined by the configured downstream interval. """ ... @@ -36,34 +33,13 @@ async def start(self) -> None: def export_metrics(self) -> List[Union[Gauge,Counter,Summary]]: """ Return metrics in Prometheus-compatible format. - - Returns: - list: A list of metric objects in Prometheus-compatible format, - ready for exposition to monitoring backends. """ ... async def run_command(self, *args, timeout=120,) -> CommandResult: """ - Execute a command asynchronously and capture its output. - - This method runs an external command in a subprocess, capturing both stdout + Executes a command asynchronously in a subprocess, capturing both stdout and stderr, with support for timeout handling. - - Args: - *args: Command and arguments to execute. Each argument is converted to string. - timeout (int, optional): Maximum time in seconds to wait for command completion. - Defaults to 120 seconds. - - Returns: - CommandResult: An object containing: - - returncode (int): The exit code of the process - - stdout (bytes): The standard output of the command - - stderr (bytes): The standard error output of the command - - Raises: - RuntimeError: If the command exceeds the specified timeout duration. - The process is terminated before raising this exception. """ start = time.monotonic() cmd_str = " ".join(str(a) for a in args) @@ -84,27 +60,14 @@ async def run_command(self, *args, timeout=120,) -> CommandResult: def launch_task(self, func, interval) -> None: """ - Launch an asynchronous task that executes a function at regular intervals. - - Args: - func: A callable function to be executed repeatedly. - interval: The time interval (in seconds) between successive executions of the function. + Launch an asynchronous task that executes a callable function at regular intervals. """ asyncio.create_task(self.__schedule(func, interval)) async def __schedule(self, func, interval: int) -> None: """ - Schedule a function to be executed periodically at specified intervals. - - Args: - func: A callable function to be scheduled for execution. - interval (int): The time interval in seconds between function executions. - - Notes: - - This is an async method that executes the function once immediately, - then schedules subsequent executions using asyncio's call_later. - - The function is executed in a non-blocking manner using asyncio. + Schedule a callable function to be executed periodically at specified intervals. """ if callable(func): reponse = await func() @@ -120,18 +83,11 @@ def __init__(self): def initialize_collectors(self) -> None: """ Initialize and start all collectors concurrently. - - Attempts to initialize three types of collectors in sequence: - Squeue: Collects job queue information - Sacct: Collects job accounting data - - Sinfo: Collects node/partition information - - Each collector is independently initialized and added to the collectors list - if available. If a collector is not available, a warning is logged and the - initialization continues with the next collector. - - Raises: - NoCollectorsFoundException: If no collectors were successfully initialized. + - Sinfo: Collects node/partition + - Azslurm: Collects partition specs + - Jetpack: Collects cluster specs """ try: from squeue import Squeue, SqueueNotAvailException @@ -160,6 +116,24 @@ def initialize_collectors(self) -> None: else: self.collectors.append(sinfo) + try: + from azslurm import Azslurm, AzslurmNotAvailException + azslurm = Azslurm() + azslurm.initialize() + except AzslurmNotAvailException: + log.warning("azslurm is not available, disabling azslurm metrics") + else: + self.collectors.append(azslurm) + + try: + from jetpack import Jetpack, JetpackNotAvailException + jetpack = Jetpack() + jetpack.initialize() + except JetpackNotAvailException: + log.warning("jetpack is not available, disabling jetpack metrics") + else: + self.collectors.append(jetpack) + if not self.collectors: log.error("No collectors intialized") raise NoCollectorsFoundException @@ -169,13 +143,6 @@ def initialize_collectors(self) -> None: def export_metrics(self) -> List[Union[Gauge,Counter,Summary]]: """ Collect and aggregate metrics from all configured collectors. - - Iterates through ``self.collectors``, calls each collector's - ``export_metrics()`` method, and combines all returned metric items - into a single list. - - Returns: - list: A flattened list containing metric objects from every collector. """ metrics = [] @@ -185,43 +152,18 @@ def export_metrics(self) -> List[Union[Gauge,Counter,Summary]]: def collect(self) -> Iterator[Metric]: """ - Collect and yield Prometheus metrics. - - This method retrieves exported metrics and iterates through them, - yielding collected metric samples for each metric object. - - Yields: - Metric samples from each exported metric's collect() method. + Collect and yield Prometheus metrics every scrape interval """ metrics = self.export_metrics() for metric in metrics: yield from metric.collect() - async def start_http_server(self, host, port) -> web.AppRunner: + async def start_http_server(self, host:str, port:int) -> web.AppRunner: """ - Start an HTTP server for Prometheus metrics collection. - - This asynchronous method initializes and starts an aiohttp web server that + Initializes and starts an aiohttp web server that exposes Prometheus metrics on the /metrics endpoint. The server listens on the specified host and port. - Args: - host (str): The host address to bind the server to (e.g., '0.0.0.0'). - port (int): The port number to listen on (e.g., 9101). - - Returns: - web.AppRunner: The runner object for the started HTTP server, which can - be used to manage the server lifecycle. - - Raises: - HTTPServerFailedException: Raised if the server fails to bind to the - specified host and port (OSError) or if any - unexpected error occurs during server startup. - - Note: - The server registers the current instance as a Prometheus collector - to expose metrics. After calling this method, metrics will be available - at at http://{host}:{port}/metrics. """ try: registry = CollectorRegistry() @@ -243,7 +185,7 @@ async def start_http_server(self, host, port) -> web.AppRunner: async def main(): - #TODO: file based logging + #TODO: file based logging logging.basicConfig( level=logging.DEBUG, format="%(asctime)s %(name)s %(levelname)s %(message)s" diff --git a/azure-slurm-exporter/jetpack.py b/azure-slurm-exporter/jetpack.py new file mode 100644 index 00000000..6571fcbd --- /dev/null +++ b/azure-slurm-exporter/jetpack.py @@ -0,0 +1,70 @@ +from exporter import BaseCollector +from collections import namedtuple +from prometheus_client import Gauge +import logging +import util as util +from typing import List +log = logging.getLogger(__name__) + +class JetpackNotAvailException(Exception): + pass + +class Jetpack(BaseCollector): + + def __init__(self, binary_path="/opt/cycle/jetpack/bin/jetpack", interval=86400, timeout=120): + self.binary_path = binary_path + self.interval = interval + self.timeout = timeout + self.cached_output = {"jetpack_metrics":[]} + self.default_options = ["config", "azure.metadata.compute.location", "None"] + + def initialize(self) -> None: + """ + Initialize the Jetpack instance by validating the binary + """ + if not util.is_file_binary(self.binary_path): + log.error(f"{self.binary_path} is not a file or not executable") + raise JetpackNotAvailException + + def start(self) -> None: + """ + Begin collecting metrics asynchronously and runs it at regular + intervals as defined by the configured downstream interval. + """ + self.launch_task(func=self.jetpack_query, interval=self.interval) + + def export_metrics(self) -> List[Gauge]: + """ + Return metrics in Prometheus-compatible format from cache. + """ + #TODO: DO we need to lock this? + return self.cached_output["jetpack_metrics"] + + def parse_output(self, stdout) -> None: + """ + Parse jetpack command stdout and return prometheus gauge for cluster specs + """ + jetpack_cluster_info = Gauge("jetpack_cluster_info", "Cluster Metadata", + labelnames=["region"], + registry=None) + region = stdout.decode().strip() + jetpack_cluster_info.labels(region=region).set(1) + return [jetpack_cluster_info] + + async def jetpack_query(self) -> None: + """ + Run jetpack query with default options and save parsed result in prometheus + metrics format to cache + """ + args = [self.binary_path] + args.extend(self.default_options) + + try: + proc = await self.run_command(timeout=self.timeout,*args) + except Exception as e: + log.error(e) + return + + self.cached_output["jetpack_metrics"] = self.parse_output(proc.stdout) + + diff --git a/azure-slurm-exporter/sacct.py b/azure-slurm-exporter/sacct.py index db945e22..19582cf9 100644 --- a/azure-slurm-exporter/sacct.py +++ b/azure-slurm-exporter/sacct.py @@ -37,7 +37,7 @@ def __init__(self, binary_path="/usr/bin/sacct", interval=300, timeout=120): self.interval = interval self.timeout = timeout self.sacct_terminal_jobs= Counter("sacct_terminal_jobs","Total Number of completed slurm jobs", - ["partition", "exit_code","reason","state"], registry=None) + ["partition", "exit_code","reason","state", "nodelist"], registry=None) self.default_output_fmt = "jobid,jobname,nodelist,nnodes,partition,exitcode,derivedexitcode,state,user,start,submit,end,reason" self.sacct_output = namedtuple("sacct_output", self.default_output_fmt) self.terminal_states = "completed,failed,cancelled,timeout,node_fail,preempted,out_of_memory,deadline,boot_fail" @@ -48,12 +48,6 @@ def __init__(self, binary_path="/usr/bin/sacct", interval=300, timeout=120): def initialize(self) -> None: """ Initialize the Sacct instance by validating the binary and disabling created metrics. - - Checks if the sacct binary exists and is executable. If the binary is not found - or is not executable, logs an error and raises an exception. - - Raises: - SacctNotAvailException: If the sacct binary is not available or not executable. """ if not util.is_file_binary(self.binary_path): log.error(f"{self.binary_path} is not a file or not executable") @@ -62,21 +56,14 @@ def initialize(self) -> None: def start(self) -> None: """ - Begin collecting metrics asynchronously. - - This method initializes the metric collection process and runs it at regular - intervals as defined by the configured downstream interval. The collection - runs asynchronously without blocking the event loop. + Begin collecting metrics asynchronously and runs it at regular + intervals as defined by the configured downstream interval. """ self.launch_task(func=self.sacct_query, interval=self.interval) def export_metrics(self) -> List[Counter]: """ Return metrics in Prometheus-compatible format. - - Returns: - list: A list of metric objects in Prometheus-compatible format, - ready for exposition to monitoring backends. """ #TODO: DO we need to lock this? return [self.sacct_terminal_jobs] @@ -84,15 +71,7 @@ def export_metrics(self) -> List[Counter]: def parse_output(self, stdout) -> None: """ Parse sacct command output and increment terminal job metrics. - - Processes the raw stdout from sacct command, splits it into lines and fields, - and updates Prometheus metrics for completed SLURM jobs with their partition, - exit code, reason, and state. - - Args: - stdout (bytes): The raw output from sacct command execution. """ - seen={} lines = stdout.decode().strip().splitlines() log.debug(f"Number of jobs:{len(lines)}") lines_iter = (line.split("|") for line in lines) @@ -102,23 +81,18 @@ def parse_output(self, stdout) -> None: partition=row.partition, exit_code=row.exitcode, reason=reason, - state=row.state).inc() + state=row.state, + nodelist=row.nodelist).inc() async def sacct_query(self) -> None: """ - Query SLURM accounting data (sacct) for jobs within a specified time window. - - This method queries sacct filtering by a time window of size `interval`, which represents - the frequency at which queries are executed. For example, if interval is 5 minutes, the - query retrieves job data from the last 5 minutes. + Queries sacct filtering by a time window of size `interval`, which represents + the frequency at which queries are executed. The time window progresses as follows: - The start time of the current query is set to the end time of the previous query - The end time is set to the current moment when the query is executed - After execution, starttime is updated to the current endtime for the next query iteration - - The method constructs and executes the sacct command with appropriate filters and handles - any exceptions that occur during query execution. Query output is parsed upon successful completion. """ args = [] self.endtime = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") diff --git a/azure-slurm-exporter/sinfo.py b/azure-slurm-exporter/sinfo.py index 27c58bbd..ce8f90df 100644 --- a/azure-slurm-exporter/sinfo.py +++ b/azure-slurm-exporter/sinfo.py @@ -38,11 +38,6 @@ def __init__(self, binary_path="/usr/bin/sinfo", interval=30, timeout=15): def initialize(self) -> None: """ Initialize the Sinfo object by validating the binary path. - - Checks if the binary file at self.binary_path exists and is executable. - - Raises: - SinfoNotAvailException: If the binary path is not a valid file or not executable. """ if not util.is_file_binary(self.binary_path): log.error(f"{self.binary_path} is not a file or not executable") @@ -50,40 +45,21 @@ def initialize(self) -> None: def start(self) -> None: """ - Begin collecting metrics asynchronously. - - This method initializes the metric collection process and runs it at regular - intervals as defined by the configured downstream interval. The collection - runs asynchronously without blocking the event loop. + Begin collecting metrics asynchronously and runs it at regular + intervals as defined by the configured downstream interval. """ self.launch_task(func=self.sinfo_query, interval=self.interval) def export_metrics(self) -> List[Gauge]: """ - Return metrics in Prometheus-compatible format. - - Returns: - list: A list of metric objects in Prometheus-compatible format, - ready for exposition to monitoring backends. + Return metrics in Prometheus-compatible format from cache. """ return self.cached_output["sinfo_query"] def normalize_state(self, state) -> str: """ - Normalize SLURM node state by mapping state suffixes to their base states. - - This method extracts the last character of the input state string and checks if it - matches a known suffix in STATE_SUFFIXES. If a matching suffix is found, it returns - the mapped state value; otherwise, it returns the original state string unchanged. - - Args: - state (str): The SLURM node state string that may contain a suffix character. - - Returns: - str: The normalized state. If the last character is a known suffix, returns the - mapped state value from STATE_SUFFIXES. Otherwise, returns the original - state string. - + Normalize SLURM node state by mapping state suffixes to their base states. If + node state has a suffix, then we set that node's state to the suffix state. """ suffix = state[-1] if suffix in self.STATE_SUFFIXES: @@ -93,19 +69,8 @@ def normalize_state(self, state) -> str: def parse_output(self, stdout) -> List[Gauge]: """ - Parse the output from the sinfo command and create a Gauge metric. - - This method processes the stdout from an sinfo command execution, parses each line - into structured data, normalizes the node state, and creates a Prometheus Gauge metric - that tracks the number of nodes in each state per partition. - - Args: - stdout (bytes): The raw output from the sinfo command as bytes. - - Returns: - list: A list containing a single Gauge metric object with labels for node_list, - partition, state, and reason, where the metric value represents the number - of nodes in that particular state. + Parse the output from the sinfo command and create a Gauge metric that track each nodelist's + state per partition. """ sinfo_partitions_nodes_state = Gauge( f"sinfo_partition_nodes_state", @@ -126,10 +91,7 @@ def parse_output(self, stdout) -> List[Gauge]: async def sinfo_query(self) -> None: """ - Execute sinfo command asynchronously and cache the results. - - This method runs the sinfo command with configured default options and a specified timeout. - The command output is parsed and stored in the cached_output dictionary for later retrieval. + Execute sinfo command asynchronously and cache the parsed output in prometheus metric format. """ args = [self.binary_path] args.extend(self.default_options) diff --git a/azure-slurm-exporter/squeue.py b/azure-slurm-exporter/squeue.py index 8bfb5c42..7b5b8046 100644 --- a/azure-slurm-exporter/squeue.py +++ b/azure-slurm-exporter/squeue.py @@ -43,9 +43,6 @@ def __init__(self, binary_path="/usr/bin/squeue", interval=60, timeout=30): def initialize(self) -> None: """ Initialize the Squeue instance and validate the binary executable. - - Raises: - SqueueNotAvailException: If the binary at self.binary_path is not a file or is not executable. """ if not util.is_file_binary(self.binary_path): log.error(f"{self.binary_path} is not a file or not executable") @@ -53,29 +50,20 @@ def initialize(self) -> None: def start(self) -> None: """ - Begin collecting metrics asynchronously. - - This method initializes the metric collection process and runs it at regular - intervals as defined by the configured downstream interval. The collection - runs asynchronously without blocking the event loop. + Begin collecting metrics asynchronously and runs it at regular + intervals as defined by the configured downstream interval. """ self.launch_task(func=self.squeue_query, interval=self.interval) def export_metrics(self) -> List[Gauge]: """ - Return metrics in Prometheus-compatible format. - - Returns: - list: A list of metric objects in Prometheus-compatible format, - ready for exposition to monitoring backends. + Return metrics in Prometheus-compatible format from cache. """ return self.cached_output["squeue_metrics"] def parse_output(self,stdout) -> List[Gauge]: """ - Parse squeue command output and create Prometheus metrics. - - This method processes the stdout from an squeue command and generates two Prometheus + Parse the stdout from an squeue command and generates two Prometheus Gauge metrics: 1. squeue_partition_jobs_state: Tracks the number of jobs in each state per partition @@ -84,14 +72,6 @@ def parse_output(self,stdout) -> List[Gauge]: 2. squeue_job_nodes_allocated: Tracks the number of nodes allocated to running jobs - Labels: job_id, job_name, partition, state - Only populated for jobs in "running" state - - Args: - stdout (bytes): The raw output from the squeue command, encoded as bytes. - - Returns: - list: A list containing two Prometheus Gauge objects: - - squeue_partition_jobs_state: Job counts aggregated by partition and state - - squeue_job_nodes_allocated: Node allocations indexed by job details """ squeue_partition_jobs_state = Gauge( f"squeue_partition_jobs_state", @@ -102,7 +82,7 @@ def parse_output(self,stdout) -> List[Gauge]: squeue_job_nodes_allocated = Gauge( "squeue_job_nodes_allocated", "Number of nodes allocated to a running job", - labelnames=["job_id", "job_name", "partition", "state"], registry=None + labelnames=["job_id", "job_name", "partition", "state", "nodelist"], registry=None ) # number of jobs per state,partition key counts = {} @@ -114,7 +94,8 @@ def parse_output(self,stdout) -> List[Gauge]: squeue_job_nodes_allocated.labels(job_id=row.jobid, job_name=row.name, partition=row.partition, - state=row.state).set(float(row.nodes)) + state=row.state, + nodelist=row.nodelist).set(float(row.nodes)) key = (row.partition, row.state.lower()) counts[key] = counts.get(key, 0) + 1 @@ -126,11 +107,8 @@ def parse_output(self,stdout) -> List[Gauge]: async def squeue_query(self) -> None: """ - Execute an squeue query asynchronously and cache the results. - - This method runs the squeue command with default options and parses the output. - The parsed metrics are stored in the cached_output dictionary under the key - 'squeue_metrics' for later retrieval. + Execute an squeue query asynchronously and cache the parsed result in prometheus + metrics format. """ args = [self.binary_path] args.extend(self.default_options) diff --git a/azure-slurm-exporter/util.py b/azure-slurm-exporter/util.py index ea8c6c68..274fc51d 100644 --- a/azure-slurm-exporter/util.py +++ b/azure-slurm-exporter/util.py @@ -3,11 +3,5 @@ def is_file_binary(binary) -> bool: """ Check if a file exists and is executable. - - Args: - binary (str): The file path to check. - - Returns: - bool: True if the file exists and is executable, False otherwise. """ return os.path.isfile(binary) and os.access(binary, os.X_OK) \ No newline at end of file From df6bbd8a70826c278bc9128094687182d7f510b1 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Tue, 3 Mar 2026 13:49:03 -0500 Subject: [PATCH 04/22] add file based logging to /var/log/azslurm-exporter.log --- .../conf/exporter_logging.conf | 31 +++++++++++++++++++ azure-slurm-exporter/exporter.py | 10 +++--- 2 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 azure-slurm-exporter/conf/exporter_logging.conf diff --git a/azure-slurm-exporter/conf/exporter_logging.conf b/azure-slurm-exporter/conf/exporter_logging.conf new file mode 100644 index 00000000..944f8a3d --- /dev/null +++ b/azure-slurm-exporter/conf/exporter_logging.conf @@ -0,0 +1,31 @@ +[loggers] +keys=root + +[handlers] +keys=consoleHandler, fileHandler + +[formatters] +keys=simpleFormatter + +[logger_root] +level=DEBUG +handlers=consoleHandler, fileHandler + +[handler_fileHandler] +class=logging.handlers.RotatingFileHandler +level=DEBUG +formatter=simpleFormatter +args=("/var/log/azslurm-exporter.log", "a", 1024 * 1024 * 5, 5) + +[handler_consoleHandler] +class=StreamHandler +level=ERROR +formatter=simpleFormatter +args=(sys.stderr,) + +[formatter_simpleFormatter] +format=%(asctime)s %(levelname)s: %(message)s +datefmt=%Y-%m-%d %H:%M:%S + +[formatter_reproFormatter] +format=%(message)s \ No newline at end of file diff --git a/azure-slurm-exporter/exporter.py b/azure-slurm-exporter/exporter.py index c41e4be6..91aa9293 100644 --- a/azure-slurm-exporter/exporter.py +++ b/azure-slurm-exporter/exporter.py @@ -1,8 +1,10 @@ import asyncio import logging +import logging.config import signal import sys import time +import os from prometheus_client import CollectorRegistry, Metric, Counter, Gauge, Summary from abc import ABC, abstractmethod from functools import partial @@ -185,11 +187,9 @@ async def start_http_server(self, host:str, port:int) -> web.AppRunner: async def main(): - #TODO: file based logging - logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s %(name)s %(levelname)s %(message)s" - ) + + if os.path.exists("exporter_logging.conf"): + logging.config.fileConfig("exporter_logging.conf") loop = asyncio.get_running_loop() stop_event = asyncio.Event() From e03a0bac3bcdfbb9f8350344999086a7cc8aa732 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Tue, 3 Mar 2026 14:38:20 -0500 Subject: [PATCH 05/22] build azure-slurm-exporter package --- azure-slurm-exporter/azslurm_metrics_curl.txt | 489 ------------------ .../current_dashboard_metrics.txt | 11 - azure-slurm-exporter/exporter/__init__.py | 3 + .../{ => exporter}/azslurm.py | 0 .../{ => exporter}/exporter.py | 2 +- .../{ => exporter}/jetpack.py | 0 azure-slurm-exporter/{ => exporter}/sacct.py | 0 azure-slurm-exporter/{ => exporter}/sinfo.py | 0 azure-slurm-exporter/{ => exporter}/squeue.py | 0 azure-slurm-exporter/{ => exporter}/util.py | 0 azure-slurm-exporter/package.py | 104 ++++ azure-slurm-exporter/package.sh | 10 + azure-slurm-exporter/setup.py | 136 +++++ project.ini | 2 +- util/build.sh | 5 + 15 files changed, 260 insertions(+), 502 deletions(-) delete mode 100644 azure-slurm-exporter/azslurm_metrics_curl.txt delete mode 100644 azure-slurm-exporter/current_dashboard_metrics.txt create mode 100644 azure-slurm-exporter/exporter/__init__.py rename azure-slurm-exporter/{ => exporter}/azslurm.py (100%) rename azure-slurm-exporter/{ => exporter}/exporter.py (100%) rename azure-slurm-exporter/{ => exporter}/jetpack.py (100%) rename azure-slurm-exporter/{ => exporter}/sacct.py (100%) rename azure-slurm-exporter/{ => exporter}/sinfo.py (100%) rename azure-slurm-exporter/{ => exporter}/squeue.py (100%) rename azure-slurm-exporter/{ => exporter}/util.py (100%) create mode 100644 azure-slurm-exporter/package.py create mode 100755 azure-slurm-exporter/package.sh create mode 100644 azure-slurm-exporter/setup.py diff --git a/azure-slurm-exporter/azslurm_metrics_curl.txt b/azure-slurm-exporter/azslurm_metrics_curl.txt deleted file mode 100644 index 2b1d2c28..00000000 --- a/azure-slurm-exporter/azslurm_metrics_curl.txt +++ /dev/null @@ -1,489 +0,0 @@ -cc-admin@azslurm-exporter-scheduler:~$ curl -s localhost:9500/metrics -# HELP python_gc_objects_collected_total Objects collected during gc -# TYPE python_gc_objects_collected_total counter -python_gc_objects_collected_total{generation="0"} 313.0 -python_gc_objects_collected_total{generation="1"} 49.0 -python_gc_objects_collected_total{generation="2"} 289.0 -# HELP python_gc_objects_uncollectable_total Uncollectable objects found during GC -# TYPE python_gc_objects_uncollectable_total counter -python_gc_objects_uncollectable_total{generation="0"} 0.0 -python_gc_objects_uncollectable_total{generation="1"} 0.0 -python_gc_objects_uncollectable_total{generation="2"} 0.0 -# HELP python_gc_collections_total Number of times this generation was collected -# TYPE python_gc_collections_total counter -python_gc_collections_total{generation="0"} 89481.0 -python_gc_collections_total{generation="1"} 8134.0 -python_gc_collections_total{generation="2"} 729.0 -# HELP python_info Python platform information -# TYPE python_info gauge -python_info{implementation="CPython",major="3",minor="11",patchlevel="0rc1",version="3.11.0rc1"} 1.0 -# HELP process_virtual_memory_bytes Virtual memory size in bytes. -# TYPE process_virtual_memory_bytes gauge -process_virtual_memory_bytes 6.14395904e+08 -# HELP process_resident_memory_bytes Resident memory size in bytes. -# TYPE process_resident_memory_bytes gauge -process_resident_memory_bytes 3.106816e+07 -# HELP process_start_time_seconds Start time of the process since unix epoch in seconds. -# TYPE process_start_time_seconds gauge -process_start_time_seconds 1.77144788156e+09 -# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds. -# TYPE process_cpu_seconds_total counter -process_cpu_seconds_total 200.66 -# HELP process_open_fds Number of open file descriptors. -# TYPE process_open_fds gauge -process_open_fds 6.0 -# HELP process_max_fds Maximum number of open file descriptors. -# TYPE process_max_fds gauge -process_max_fds 1024.0 -# HELP squeue_jobs Slurm job queue metric: squeue_jobs -# TYPE squeue_jobs gauge -squeue_jobs 0.0 -# HELP squeue_jobs_running Slurm job queue metric: squeue_jobs_running -# TYPE squeue_jobs_running gauge -squeue_jobs_running 0.0 -# HELP squeue_jobs_pending Slurm job queue metric: squeue_jobs_pending -# TYPE squeue_jobs_pending gauge -squeue_jobs_pending 0.0 -# HELP squeue_jobs_configuring Slurm job queue metric: squeue_jobs_configuring -# TYPE squeue_jobs_configuring gauge -squeue_jobs_configuring 0.0 -# HELP squeue_jobs_completing Slurm job queue metric: squeue_jobs_completing -# TYPE squeue_jobs_completing gauge -squeue_jobs_completing 0.0 -# HELP squeue_jobs_suspended Slurm job queue metric: squeue_jobs_suspended -# TYPE squeue_jobs_suspended gauge -squeue_jobs_suspended 0.0 -# HELP squeue_jobs_failed Slurm job queue metric: squeue_jobs_failed -# TYPE squeue_jobs_failed gauge -squeue_jobs_failed 0.0 -# HELP squeue_job_nodes_allocated Number of nodes allocated/requested per job -# TYPE squeue_job_nodes_allocated gauge -# HELP sacct_jobs_total_completed Slurm cumulative job metric: sacct_jobs_total_completed -# TYPE sacct_jobs_total_completed gauge -sacct_jobs_total_completed 223.0 -# HELP sacct_jobs_total_failed Slurm cumulative job metric: sacct_jobs_total_failed -# TYPE sacct_jobs_total_failed gauge -sacct_jobs_total_failed 51.0 -# HELP sacct_jobs_total_timeout Slurm cumulative job metric: sacct_jobs_total_timeout -# TYPE sacct_jobs_total_timeout gauge -sacct_jobs_total_timeout 23.0 -# HELP sacct_jobs_total_cancelled Slurm cumulative job metric: sacct_jobs_total_cancelled -# TYPE sacct_jobs_total_cancelled gauge -sacct_jobs_total_cancelled 3.0 -# HELP sacct_jobs_total_node_failed Slurm cumulative job metric: sacct_jobs_total_node_failed -# TYPE sacct_jobs_total_node_failed gauge -sacct_jobs_total_node_failed 0.0 -# HELP sacct_jobs_total_out_of_memory Slurm cumulative job metric: sacct_jobs_total_out_of_memory -# TYPE sacct_jobs_total_out_of_memory gauge -sacct_jobs_total_out_of_memory 0.0 -# HELP sacct_jobs_total_submitted Slurm cumulative job metric: sacct_jobs_total_submitted -# TYPE sacct_jobs_total_submitted gauge -sacct_jobs_total_submitted 301.0 -# HELP scontrol_nodes Slurm node metric: scontrol_nodes -# TYPE scontrol_nodes gauge -scontrol_nodes 88.0 -# HELP scontrol_nodes_powered_down Slurm node metric: scontrol_nodes_powered_down -# TYPE scontrol_nodes_powered_down gauge -scontrol_nodes_powered_down 72.0 -# HELP scontrol_nodes_powering_up Slurm node metric: scontrol_nodes_powering_up -# TYPE scontrol_nodes_powering_up gauge -scontrol_nodes_powering_up 0.0 -# HELP scontrol_nodes_down Slurm node metric: scontrol_nodes_down -# TYPE scontrol_nodes_down gauge -scontrol_nodes_down 0.0 -# HELP scontrol_nodes_fail Slurm node metric: scontrol_nodes_fail -# TYPE scontrol_nodes_fail gauge -scontrol_nodes_fail 0.0 -# HELP scontrol_nodes_drained Slurm node metric: scontrol_nodes_drained -# TYPE scontrol_nodes_drained gauge -scontrol_nodes_drained 3.0 -# HELP scontrol_nodes_draining Slurm node metric: scontrol_nodes_draining -# TYPE scontrol_nodes_draining gauge -scontrol_nodes_draining 0.0 -# HELP scontrol_nodes_maint Slurm node metric: scontrol_nodes_maint -# TYPE scontrol_nodes_maint gauge -scontrol_nodes_maint 0.0 -# HELP scontrol_nodes_resv Slurm node metric: scontrol_nodes_resv -# TYPE scontrol_nodes_resv gauge -scontrol_nodes_resv 0.0 -# HELP scontrol_nodes_completing Slurm node metric: scontrol_nodes_completing -# TYPE scontrol_nodes_completing gauge -scontrol_nodes_completing 0.0 -# HELP scontrol_nodes_alloc Slurm node metric: scontrol_nodes_alloc -# TYPE scontrol_nodes_alloc gauge -scontrol_nodes_alloc 0.0 -# HELP scontrol_nodes_mixed Slurm node metric: scontrol_nodes_mixed -# TYPE scontrol_nodes_mixed gauge -scontrol_nodes_mixed 0.0 -# HELP scontrol_nodes_idle Slurm node metric: scontrol_nodes_idle -# TYPE scontrol_nodes_idle gauge -scontrol_nodes_idle 13.0 -# HELP scontrol_nodes_cloud Slurm node metric: scontrol_nodes_cloud -# TYPE scontrol_nodes_cloud gauge -scontrol_nodes_cloud 88.0 -# HELP squeue_partition_jobs Slurm partition job metric: squeue_partition_jobs -# TYPE squeue_partition_jobs gauge -squeue_partition_jobs{partition="dynamic"} 0.0 -squeue_partition_jobs{partition="gpu"} 0.0 -squeue_partition_jobs{partition="hpc"} 0.0 -squeue_partition_jobs{partition="htc"} 0.0 -# HELP squeue_partition_jobs_running Slurm partition job metric: squeue_partition_jobs_running -# TYPE squeue_partition_jobs_running gauge -squeue_partition_jobs_running{partition="dynamic"} 0.0 -squeue_partition_jobs_running{partition="gpu"} 0.0 -squeue_partition_jobs_running{partition="hpc"} 0.0 -squeue_partition_jobs_running{partition="htc"} 0.0 -# HELP squeue_partition_jobs_pending Slurm partition job metric: squeue_partition_jobs_pending -# TYPE squeue_partition_jobs_pending gauge -squeue_partition_jobs_pending{partition="dynamic"} 0.0 -squeue_partition_jobs_pending{partition="gpu"} 0.0 -squeue_partition_jobs_pending{partition="hpc"} 0.0 -squeue_partition_jobs_pending{partition="htc"} 0.0 -# HELP squeue_partition_jobs_configuring Slurm partition job metric: squeue_partition_jobs_configuring -# TYPE squeue_partition_jobs_configuring gauge -squeue_partition_jobs_configuring{partition="dynamic"} 0.0 -squeue_partition_jobs_configuring{partition="gpu"} 0.0 -squeue_partition_jobs_configuring{partition="hpc"} 0.0 -squeue_partition_jobs_configuring{partition="htc"} 0.0 -# HELP squeue_partition_jobs_completing Slurm partition job metric: squeue_partition_jobs_completing -# TYPE squeue_partition_jobs_completing gauge -squeue_partition_jobs_completing{partition="dynamic"} 0.0 -squeue_partition_jobs_completing{partition="gpu"} 0.0 -squeue_partition_jobs_completing{partition="hpc"} 0.0 -squeue_partition_jobs_completing{partition="htc"} 0.0 -# HELP squeue_partition_jobs_suspended Slurm partition job metric: squeue_partition_jobs_suspended -# TYPE squeue_partition_jobs_suspended gauge -squeue_partition_jobs_suspended{partition="dynamic"} 0.0 -squeue_partition_jobs_suspended{partition="gpu"} 0.0 -squeue_partition_jobs_suspended{partition="hpc"} 0.0 -squeue_partition_jobs_suspended{partition="htc"} 0.0 -# HELP squeue_partition_jobs_failed Slurm partition job metric: squeue_partition_jobs_failed -# TYPE squeue_partition_jobs_failed gauge -squeue_partition_jobs_failed{partition="dynamic"} 0.0 -squeue_partition_jobs_failed{partition="gpu"} 0.0 -squeue_partition_jobs_failed{partition="hpc"} 0.0 -squeue_partition_jobs_failed{partition="htc"} 0.0 -# HELP scontrol_partition_nodes Total nodes per partition -# TYPE scontrol_partition_nodes gauge -scontrol_partition_nodes{partition="dynamic"} 21.0 -scontrol_partition_nodes{partition="gpu"} 1.0 -scontrol_partition_nodes{partition="hpc"} 16.0 -scontrol_partition_nodes{partition="htc"} 50.0 -# HELP scontrol_partition_nodes_powered_down Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_powered_down -# TYPE scontrol_partition_nodes_powered_down gauge -scontrol_partition_nodes_powered_down{nodelist="dyn1-[1-10],dyn2-[1-10],g1",partition="dynamic",reason="none"} 21.0 -scontrol_partition_nodes_powered_down{nodelist="azslurm-exporter-gpu-1",partition="gpu",reason="none"} 1.0 -scontrol_partition_nodes_powered_down{nodelist="azslurm-exporter-htc-[1-50]",partition="htc",reason="none"} 50.0 -scontrol_partition_nodes_powered_down{nodelist="none",partition="hpc",reason="none"} 0.0 -# HELP scontrol_partition_nodes_powering_up Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_powering_up -# TYPE scontrol_partition_nodes_powering_up gauge -scontrol_partition_nodes_powering_up{nodelist="none",partition="hpc",reason="none"} 0.0 -scontrol_partition_nodes_powering_up{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_powering_up{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_powering_up{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP scontrol_partition_nodes_down Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_down -# TYPE scontrol_partition_nodes_down gauge -scontrol_partition_nodes_down{nodelist="none",partition="hpc",reason="none"} 0.0 -scontrol_partition_nodes_down{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_down{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_down{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP scontrol_partition_nodes_fail Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_fail -# TYPE scontrol_partition_nodes_fail gauge -scontrol_partition_nodes_fail{nodelist="none",partition="hpc",reason="none"} 0.0 -scontrol_partition_nodes_fail{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_fail{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_fail{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP scontrol_partition_nodes_drained Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_drained -# TYPE scontrol_partition_nodes_drained gauge -scontrol_partition_nodes_drained{nodelist="azslurm-exporter-hpc-[1-3]",partition="hpc",reason="blah"} 3.0 -scontrol_partition_nodes_drained{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_drained{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_drained{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP scontrol_partition_nodes_draining Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_draining -# TYPE scontrol_partition_nodes_draining gauge -scontrol_partition_nodes_draining{nodelist="none",partition="hpc",reason="none"} 0.0 -scontrol_partition_nodes_draining{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_draining{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_draining{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP scontrol_partition_nodes_maint Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_maint -# TYPE scontrol_partition_nodes_maint gauge -scontrol_partition_nodes_maint{nodelist="none",partition="hpc",reason="none"} 0.0 -scontrol_partition_nodes_maint{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_maint{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_maint{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP scontrol_partition_nodes_resv Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_resv -# TYPE scontrol_partition_nodes_resv gauge -scontrol_partition_nodes_resv{nodelist="none",partition="hpc",reason="none"} 0.0 -scontrol_partition_nodes_resv{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_resv{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_resv{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP scontrol_partition_nodes_completing Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_completing -# TYPE scontrol_partition_nodes_completing gauge -scontrol_partition_nodes_completing{nodelist="none",partition="hpc",reason="none"} 0.0 -scontrol_partition_nodes_completing{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_completing{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_completing{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP scontrol_partition_nodes_alloc Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_alloc -# TYPE scontrol_partition_nodes_alloc gauge -scontrol_partition_nodes_alloc{nodelist="none",partition="hpc",reason="none"} 0.0 -scontrol_partition_nodes_alloc{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_alloc{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_alloc{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP scontrol_partition_nodes_mixed Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_mixed -# TYPE scontrol_partition_nodes_mixed gauge -scontrol_partition_nodes_mixed{nodelist="none",partition="hpc",reason="none"} 0.0 -scontrol_partition_nodes_mixed{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_mixed{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_mixed{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP scontrol_partition_nodes_idle Slurm partition node metric with nodelist and reason: scontrol_partition_nodes_idle -# TYPE scontrol_partition_nodes_idle gauge -scontrol_partition_nodes_idle{nodelist="azslurm-exporter-hpc-[4-16]",partition="hpc",reason="none"} 13.0 -scontrol_partition_nodes_idle{nodelist="none",partition="htc",reason="none"} 0.0 -scontrol_partition_nodes_idle{nodelist="none",partition="dynamic",reason="none"} 0.0 -scontrol_partition_nodes_idle{nodelist="none",partition="gpu",reason="none"} 0.0 -# HELP sacct_partition_jobs_total_completed Slurm partition cumulative job metric: sacct_partition_jobs_total_completed -# TYPE sacct_partition_jobs_total_completed gauge -sacct_partition_jobs_total_completed{partition="hpc"} 138.0 -sacct_partition_jobs_total_completed{partition="htc"} 80.0 -sacct_partition_jobs_total_completed{partition="dynamic"} 5.0 -# HELP sacct_partition_jobs_total_failed Slurm partition cumulative job metric: sacct_partition_jobs_total_failed -# TYPE sacct_partition_jobs_total_failed gauge -sacct_partition_jobs_total_failed{partition="hpc"} 29.0 -sacct_partition_jobs_total_failed{partition="htc"} 22.0 -# HELP sacct_partition_jobs_total_timeout Slurm partition cumulative job metric: sacct_partition_jobs_total_timeout -# TYPE sacct_partition_jobs_total_timeout gauge -sacct_partition_jobs_total_timeout{partition="hpc"} 15.0 -sacct_partition_jobs_total_timeout{partition="htc"} 8.0 -# HELP sacct_partition_jobs_total_submitted Slurm partition cumulative job metric: sacct_partition_jobs_total_submitted -# TYPE sacct_partition_jobs_total_submitted gauge -sacct_partition_jobs_total_submitted{partition="hpc"} 182.0 -sacct_partition_jobs_total_submitted{partition="htc"} 112.0 -sacct_partition_jobs_total_submitted{partition="dynamic"} 6.0 -sacct_partition_jobs_total_submitted{partition="gpu"} 1.0 -# HELP sacct_partition_jobs_total_cancelled Slurm partition cumulative job metric: sacct_partition_jobs_total_cancelled -# TYPE sacct_partition_jobs_total_cancelled gauge -sacct_partition_jobs_total_cancelled{partition="htc"} 2.0 -sacct_partition_jobs_total_cancelled{partition="gpu"} 1.0 -# HELP sacct_partition_jobs_total_node_failed Slurm partition cumulative job metric: sacct_partition_jobs_total_node_failed -# TYPE sacct_partition_jobs_total_node_failed gauge -sacct_partition_jobs_total_node_failed{partition="dynamic"} 1.0 -# HELP sacct_jobs_total_six_months_submitted Slurm 6-month rolling job metric: submitted -# TYPE sacct_jobs_total_six_months_submitted gauge -sacct_jobs_total_six_months_submitted{start_date="2025-08-23"} 301.0 -# HELP sacct_jobs_total_six_months_completed Slurm 6-month rolling job metric: completed -# TYPE sacct_jobs_total_six_months_completed gauge -sacct_jobs_total_six_months_completed{start_date="2025-08-23"} 223.0 -# HELP sacct_jobs_total_six_months_failed Slurm 6-month rolling job metric: failed -# TYPE sacct_jobs_total_six_months_failed gauge -sacct_jobs_total_six_months_failed{start_date="2025-08-23"} 51.0 -# HELP sacct_jobs_total_six_months_timeout Slurm 6-month rolling job metric: timeout -# TYPE sacct_jobs_total_six_months_timeout gauge -sacct_jobs_total_six_months_timeout{start_date="2025-08-23"} 23.0 -# HELP sacct_jobs_total_six_months_node_failed Slurm 6-month rolling job metric: node_failed -# TYPE sacct_jobs_total_six_months_node_failed gauge -sacct_jobs_total_six_months_node_failed{start_date="2025-08-23"} 1.0 -# HELP sacct_jobs_total_six_months_cancelled Slurm 6-month rolling job metric: cancelled -# TYPE sacct_jobs_total_six_months_cancelled gauge -sacct_jobs_total_six_months_cancelled{start_date="2025-08-23"} 3.0 -# HELP sacct_jobs_total_six_months_by_state Slurm 6-month rolling job metric by state -# TYPE sacct_jobs_total_six_months_by_state gauge -sacct_jobs_total_six_months_by_state{start_date="2025-08-23",state="completed"} 223.0 -sacct_jobs_total_six_months_by_state{start_date="2025-08-23",state="failed"} 51.0 -sacct_jobs_total_six_months_by_state{start_date="2025-08-23",state="timeout"} 23.0 -sacct_jobs_total_six_months_by_state{start_date="2025-08-23",state="node_failed"} 1.0 -sacct_jobs_total_six_months_by_state{start_date="2025-08-23",state="cancelled"} 3.0 -# HELP sacct_jobs_total_six_months_by_state_exit_code Slurm 6-month rolling job metric by state and exit code -# TYPE sacct_jobs_total_six_months_by_state_exit_code gauge -sacct_jobs_total_six_months_by_state_exit_code{exit_code="1:0",reason="General failure",start_date="2025-08-23",state="failed"} 27.0 -sacct_jobs_total_six_months_by_state_exit_code{exit_code="127:0",reason="Command not found",start_date="2025-08-23",state="failed"} 11.0 -sacct_jobs_total_six_months_by_state_exit_code{exit_code="2:0",reason="Misuse of shell built-in",start_date="2025-08-23",state="failed"} 4.0 -sacct_jobs_total_six_months_by_state_exit_code{exit_code="137:0",reason="SIGKILL - Force killed",start_date="2025-08-23",state="failed"} 4.0 -sacct_jobs_total_six_months_by_state_exit_code{exit_code="143:0",reason="SIGTERM - Terminated",start_date="2025-08-23",state="failed"} 3.0 -sacct_jobs_total_six_months_by_state_exit_code{exit_code="42:0",reason="Other",start_date="2025-08-23",state="failed"} 1.0 -sacct_jobs_total_six_months_by_state_exit_code{exit_code="255:0",reason="Other",start_date="2025-08-23",state="failed"} 1.0 -sacct_jobs_total_six_months_by_state_exit_code{exit_code="0:0",reason="",start_date="2025-08-23",state="timeout"} 23.0 -sacct_jobs_total_six_months_by_state_exit_code{exit_code="1:0",reason="General failure",start_date="2025-08-23",state="node_failed"} 1.0 -sacct_jobs_total_six_months_by_state_exit_code{exit_code="0:0",reason="",start_date="2025-08-23",state="cancelled"} 3.0 -# HELP sacct_partition_jobs_total_six_months_submitted Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_submitted -# TYPE sacct_partition_jobs_total_six_months_submitted gauge -sacct_partition_jobs_total_six_months_submitted{partition="hpc",start_date="2025-08-23"} 182.0 -sacct_partition_jobs_total_six_months_submitted{partition="htc",start_date="2025-08-23"} 112.0 -sacct_partition_jobs_total_six_months_submitted{partition="dynamic",start_date="2025-08-23"} 6.0 -sacct_partition_jobs_total_six_months_submitted{partition="gpu",start_date="2025-08-23"} 1.0 -# HELP sacct_partition_jobs_total_six_months_completed Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_completed -# TYPE sacct_partition_jobs_total_six_months_completed gauge -sacct_partition_jobs_total_six_months_completed{partition="hpc",start_date="2025-08-23"} 138.0 -sacct_partition_jobs_total_six_months_completed{partition="htc",start_date="2025-08-23"} 80.0 -sacct_partition_jobs_total_six_months_completed{partition="dynamic",start_date="2025-08-23"} 5.0 -# HELP sacct_partition_jobs_total_six_months_failed Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_failed -# TYPE sacct_partition_jobs_total_six_months_failed gauge -sacct_partition_jobs_total_six_months_failed{partition="hpc",start_date="2025-08-23"} 29.0 -sacct_partition_jobs_total_six_months_failed{partition="htc",start_date="2025-08-23"} 22.0 -# HELP sacct_partition_jobs_total_six_months_timeout Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_timeout -# TYPE sacct_partition_jobs_total_six_months_timeout gauge -sacct_partition_jobs_total_six_months_timeout{partition="hpc",start_date="2025-08-23"} 15.0 -sacct_partition_jobs_total_six_months_timeout{partition="htc",start_date="2025-08-23"} 8.0 -# HELP sacct_partition_jobs_total_six_months_cancelled Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_cancelled -# TYPE sacct_partition_jobs_total_six_months_cancelled gauge -sacct_partition_jobs_total_six_months_cancelled{partition="htc",start_date="2025-08-23"} 2.0 -sacct_partition_jobs_total_six_months_cancelled{partition="gpu",start_date="2025-08-23"} 1.0 -# HELP sacct_partition_jobs_total_six_months_node_failed Slurm partition 6-month rolling job metric: sacct_partition_jobs_total_six_months_node_failed -# TYPE sacct_partition_jobs_total_six_months_node_failed gauge -sacct_partition_jobs_total_six_months_node_failed{partition="dynamic",start_date="2025-08-23"} 1.0 -# HELP sacct_partition_jobs_total_six_months_by_state_exit_code Slurm partition 6-month rolling job metric by state and exit code -# TYPE sacct_partition_jobs_total_six_months_by_state_exit_code gauge -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="1:0",partition="hpc",reason="General failure",start_date="2025-08-23",state="failed"} 15.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="127:0",partition="hpc",reason="Command not found",start_date="2025-08-23",state="failed"} 6.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="2:0",partition="hpc",reason="Misuse of shell built-in",start_date="2025-08-23",state="failed"} 3.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="137:0",partition="hpc",reason="SIGKILL - Force killed",start_date="2025-08-23",state="failed"} 3.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="143:0",partition="hpc",reason="SIGTERM - Terminated",start_date="2025-08-23",state="failed"} 1.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="42:0",partition="hpc",reason="Other",start_date="2025-08-23",state="failed"} 1.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="0:0",partition="hpc",reason="",start_date="2025-08-23",state="timeout"} 15.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="1:0",partition="htc",reason="General failure",start_date="2025-08-23",state="failed"} 12.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="127:0",partition="htc",reason="Command not found",start_date="2025-08-23",state="failed"} 5.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="2:0",partition="htc",reason="Misuse of shell built-in",start_date="2025-08-23",state="failed"} 1.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="137:0",partition="htc",reason="SIGKILL - Force killed",start_date="2025-08-23",state="failed"} 1.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="143:0",partition="htc",reason="SIGTERM - Terminated",start_date="2025-08-23",state="failed"} 2.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="255:0",partition="htc",reason="Other",start_date="2025-08-23",state="failed"} 1.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="0:0",partition="htc",reason="",start_date="2025-08-23",state="timeout"} 8.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="0:0",partition="htc",reason="",start_date="2025-08-23",state="cancelled"} 2.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="1:0",partition="dynamic",reason="General failure",start_date="2025-08-23",state="node_failed"} 1.0 -sacct_partition_jobs_total_six_months_by_state_exit_code{exit_code="0:0",partition="gpu",reason="",start_date="2025-08-23",state="cancelled"} 1.0 -# HELP sacct_jobs_total_one_week_submitted Slurm 1-week rolling job metric: submitted -# TYPE sacct_jobs_total_one_week_submitted gauge -sacct_jobs_total_one_week_submitted{start_date="2026-02-12"} 38.0 -# HELP sacct_jobs_total_one_week_completed Slurm 1-week rolling job metric: completed -# TYPE sacct_jobs_total_one_week_completed gauge -sacct_jobs_total_one_week_completed{start_date="2026-02-12"} 30.0 -# HELP sacct_jobs_total_one_week_failed Slurm 1-week rolling job metric: failed -# TYPE sacct_jobs_total_one_week_failed gauge -sacct_jobs_total_one_week_failed{start_date="2026-02-12"} 8.0 -# HELP sacct_jobs_total_one_week_by_state Slurm 1-week rolling job metric by state -# TYPE sacct_jobs_total_one_week_by_state gauge -sacct_jobs_total_one_week_by_state{start_date="2026-02-12",state="completed"} 30.0 -sacct_jobs_total_one_week_by_state{start_date="2026-02-12",state="failed"} 8.0 -# HELP sacct_jobs_total_one_week_by_state_exit_code Slurm 1-week rolling job metric by state and exit code -# TYPE sacct_jobs_total_one_week_by_state_exit_code gauge -sacct_jobs_total_one_week_by_state_exit_code{exit_code="1:0",reason="General failure",start_date="2026-02-12",state="failed"} 3.0 -sacct_jobs_total_one_week_by_state_exit_code{exit_code="42:0",reason="Other",start_date="2026-02-12",state="failed"} 1.0 -sacct_jobs_total_one_week_by_state_exit_code{exit_code="127:0",reason="Command not found",start_date="2026-02-12",state="failed"} 3.0 -sacct_jobs_total_one_week_by_state_exit_code{exit_code="255:0",reason="Other",start_date="2026-02-12",state="failed"} 1.0 -# HELP sacct_partition_jobs_total_one_week_submitted Slurm partition 1-week rolling job metric: sacct_partition_jobs_total_one_week_submitted -# TYPE sacct_partition_jobs_total_one_week_submitted gauge -sacct_partition_jobs_total_one_week_submitted{partition="hpc",start_date="2026-02-12"} 28.0 -sacct_partition_jobs_total_one_week_submitted{partition="htc",start_date="2026-02-12"} 10.0 -# HELP sacct_partition_jobs_total_one_week_completed Slurm partition 1-week rolling job metric: sacct_partition_jobs_total_one_week_completed -# TYPE sacct_partition_jobs_total_one_week_completed gauge -sacct_partition_jobs_total_one_week_completed{partition="hpc",start_date="2026-02-12"} 23.0 -sacct_partition_jobs_total_one_week_completed{partition="htc",start_date="2026-02-12"} 7.0 -# HELP sacct_partition_jobs_total_one_week_failed Slurm partition 1-week rolling job metric: sacct_partition_jobs_total_one_week_failed -# TYPE sacct_partition_jobs_total_one_week_failed gauge -sacct_partition_jobs_total_one_week_failed{partition="hpc",start_date="2026-02-12"} 5.0 -sacct_partition_jobs_total_one_week_failed{partition="htc",start_date="2026-02-12"} 3.0 -# HELP sacct_partition_jobs_total_one_week_by_state_exit_code Slurm partition 1-week rolling job metric by state and exit code -# TYPE sacct_partition_jobs_total_one_week_by_state_exit_code gauge -sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="1:0",partition="hpc",reason="General failure",start_date="2026-02-12",state="failed"} 2.0 -sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="42:0",partition="hpc",reason="Other",start_date="2026-02-12",state="failed"} 1.0 -sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="127:0",partition="hpc",reason="Command not found",start_date="2026-02-12",state="failed"} 2.0 -sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="127:0",partition="htc",reason="Command not found",start_date="2026-02-12",state="failed"} 1.0 -sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="1:0",partition="htc",reason="General failure",start_date="2026-02-12",state="failed"} 1.0 -sacct_partition_jobs_total_one_week_by_state_exit_code{exit_code="255:0",partition="htc",reason="Other",start_date="2026-02-12",state="failed"} 1.0 -# HELP sacct_jobs_total_one_month_submitted Slurm 30-day rolling job metric: submitted -# TYPE sacct_jobs_total_one_month_submitted gauge -sacct_jobs_total_one_month_submitted{start_date="2026-01-20"} 301.0 -# HELP sacct_jobs_total_one_month_completed Slurm 30-day rolling job metric: completed -# TYPE sacct_jobs_total_one_month_completed gauge -sacct_jobs_total_one_month_completed{start_date="2026-01-20"} 223.0 -# HELP sacct_jobs_total_one_month_failed Slurm 30-day rolling job metric: failed -# TYPE sacct_jobs_total_one_month_failed gauge -sacct_jobs_total_one_month_failed{start_date="2026-01-20"} 51.0 -# HELP sacct_jobs_total_one_month_timeout Slurm 30-day rolling job metric: timeout -# TYPE sacct_jobs_total_one_month_timeout gauge -sacct_jobs_total_one_month_timeout{start_date="2026-01-20"} 23.0 -# HELP sacct_jobs_total_one_month_node_failed Slurm 30-day rolling job metric: node_failed -# TYPE sacct_jobs_total_one_month_node_failed gauge -sacct_jobs_total_one_month_node_failed{start_date="2026-01-20"} 1.0 -# HELP sacct_jobs_total_one_month_cancelled Slurm 30-day rolling job metric: cancelled -# TYPE sacct_jobs_total_one_month_cancelled gauge -sacct_jobs_total_one_month_cancelled{start_date="2026-01-20"} 3.0 -# HELP sacct_jobs_total_one_month_by_state Slurm 30-day rolling job metric by state -# TYPE sacct_jobs_total_one_month_by_state gauge -sacct_jobs_total_one_month_by_state{start_date="2026-01-20",state="completed"} 223.0 -sacct_jobs_total_one_month_by_state{start_date="2026-01-20",state="failed"} 51.0 -sacct_jobs_total_one_month_by_state{start_date="2026-01-20",state="timeout"} 23.0 -sacct_jobs_total_one_month_by_state{start_date="2026-01-20",state="node_failed"} 1.0 -sacct_jobs_total_one_month_by_state{start_date="2026-01-20",state="cancelled"} 3.0 -# HELP sacct_jobs_total_one_month_by_state_exit_code Slurm 30-day rolling job metric by state and exit code -# TYPE sacct_jobs_total_one_month_by_state_exit_code gauge -sacct_jobs_total_one_month_by_state_exit_code{exit_code="1:0",reason="General failure",start_date="2026-01-20",state="failed"} 27.0 -sacct_jobs_total_one_month_by_state_exit_code{exit_code="127:0",reason="Command not found",start_date="2026-01-20",state="failed"} 11.0 -sacct_jobs_total_one_month_by_state_exit_code{exit_code="2:0",reason="Misuse of shell built-in",start_date="2026-01-20",state="failed"} 4.0 -sacct_jobs_total_one_month_by_state_exit_code{exit_code="137:0",reason="SIGKILL - Force killed",start_date="2026-01-20",state="failed"} 4.0 -sacct_jobs_total_one_month_by_state_exit_code{exit_code="143:0",reason="SIGTERM - Terminated",start_date="2026-01-20",state="failed"} 3.0 -sacct_jobs_total_one_month_by_state_exit_code{exit_code="42:0",reason="Other",start_date="2026-01-20",state="failed"} 1.0 -sacct_jobs_total_one_month_by_state_exit_code{exit_code="255:0",reason="Other",start_date="2026-01-20",state="failed"} 1.0 -sacct_jobs_total_one_month_by_state_exit_code{exit_code="0:0",reason="",start_date="2026-01-20",state="timeout"} 23.0 -sacct_jobs_total_one_month_by_state_exit_code{exit_code="1:0",reason="General failure",start_date="2026-01-20",state="node_failed"} 1.0 -sacct_jobs_total_one_month_by_state_exit_code{exit_code="0:0",reason="",start_date="2026-01-20",state="cancelled"} 3.0 -# HELP sacct_partition_jobs_total_one_month_submitted Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_submitted -# TYPE sacct_partition_jobs_total_one_month_submitted gauge -sacct_partition_jobs_total_one_month_submitted{partition="hpc",start_date="2026-01-20"} 182.0 -sacct_partition_jobs_total_one_month_submitted{partition="htc",start_date="2026-01-20"} 112.0 -sacct_partition_jobs_total_one_month_submitted{partition="dynamic",start_date="2026-01-20"} 6.0 -sacct_partition_jobs_total_one_month_submitted{partition="gpu",start_date="2026-01-20"} 1.0 -# HELP sacct_partition_jobs_total_one_month_completed Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_completed -# TYPE sacct_partition_jobs_total_one_month_completed gauge -sacct_partition_jobs_total_one_month_completed{partition="hpc",start_date="2026-01-20"} 138.0 -sacct_partition_jobs_total_one_month_completed{partition="htc",start_date="2026-01-20"} 80.0 -sacct_partition_jobs_total_one_month_completed{partition="dynamic",start_date="2026-01-20"} 5.0 -# HELP sacct_partition_jobs_total_one_month_failed Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_failed -# TYPE sacct_partition_jobs_total_one_month_failed gauge -sacct_partition_jobs_total_one_month_failed{partition="hpc",start_date="2026-01-20"} 29.0 -sacct_partition_jobs_total_one_month_failed{partition="htc",start_date="2026-01-20"} 22.0 -# HELP sacct_partition_jobs_total_one_month_timeout Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_timeout -# TYPE sacct_partition_jobs_total_one_month_timeout gauge -sacct_partition_jobs_total_one_month_timeout{partition="hpc",start_date="2026-01-20"} 15.0 -sacct_partition_jobs_total_one_month_timeout{partition="htc",start_date="2026-01-20"} 8.0 -# HELP sacct_partition_jobs_total_one_month_cancelled Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_cancelled -# TYPE sacct_partition_jobs_total_one_month_cancelled gauge -sacct_partition_jobs_total_one_month_cancelled{partition="htc",start_date="2026-01-20"} 2.0 -sacct_partition_jobs_total_one_month_cancelled{partition="gpu",start_date="2026-01-20"} 1.0 -# HELP sacct_partition_jobs_total_one_month_node_failed Slurm partition 30-day rolling job metric: sacct_partition_jobs_total_one_month_node_failed -# TYPE sacct_partition_jobs_total_one_month_node_failed gauge -sacct_partition_jobs_total_one_month_node_failed{partition="dynamic",start_date="2026-01-20"} 1.0 -# HELP sacct_partition_jobs_total_one_month_by_state_exit_code Slurm partition 30-day rolling job metric by state and exit code -# TYPE sacct_partition_jobs_total_one_month_by_state_exit_code gauge -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="1:0",partition="hpc",reason="General failure",start_date="2026-01-20",state="failed"} 15.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="127:0",partition="hpc",reason="Command not found",start_date="2026-01-20",state="failed"} 6.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="2:0",partition="hpc",reason="Misuse of shell built-in",start_date="2026-01-20",state="failed"} 3.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="137:0",partition="hpc",reason="SIGKILL - Force killed",start_date="2026-01-20",state="failed"} 3.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="143:0",partition="hpc",reason="SIGTERM - Terminated",start_date="2026-01-20",state="failed"} 1.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="42:0",partition="hpc",reason="Other",start_date="2026-01-20",state="failed"} 1.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="0:0",partition="hpc",reason="",start_date="2026-01-20",state="timeout"} 15.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="1:0",partition="htc",reason="General failure",start_date="2026-01-20",state="failed"} 12.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="127:0",partition="htc",reason="Command not found",start_date="2026-01-20",state="failed"} 5.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="2:0",partition="htc",reason="Misuse of shell built-in",start_date="2026-01-20",state="failed"} 1.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="137:0",partition="htc",reason="SIGKILL - Force killed",start_date="2026-01-20",state="failed"} 1.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="143:0",partition="htc",reason="SIGTERM - Terminated",start_date="2026-01-20",state="failed"} 2.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="255:0",partition="htc",reason="Other",start_date="2026-01-20",state="failed"} 1.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="0:0",partition="htc",reason="",start_date="2026-01-20",state="timeout"} 8.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="0:0",partition="htc",reason="",start_date="2026-01-20",state="cancelled"} 2.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="1:0",partition="dynamic",reason="General failure",start_date="2026-01-20",state="node_failed"} 1.0 -sacct_partition_jobs_total_one_month_by_state_exit_code{exit_code="0:0",partition="gpu",reason="",start_date="2026-01-20",state="cancelled"} 1.0 -# HELP azslurm_cluster_info Static cluster information from azslurm -# TYPE azslurm_cluster_info gauge -azslurm_cluster_info{cluster_name="azslurm-exporter",region="westeurope",resource_group="azcyclecloudwesteu-rg",subscription_id="cbbe2034-c78b-4e9b-89b4-8b78530247e5"} 1.0 -# HELP azslurm_partition_info Static partition information from azslurm with VM size and node details -# TYPE azslurm_partition_info gauge -azslurm_partition_info{available_azure_quota="4",node_list="dyn1-[1-10],dyn2-[1-10],g1",partition="dynamic",vm_size="Standard_F2s_v2"} 4.0 -azslurm_partition_info{available_azure_quota="4",node_list="dyn1-[1-10],dyn2-[1-10],g1",partition="dynamic",vm_size="Standard_D2ds_v5"} 4.0 -azslurm_partition_info{available_azure_quota="0",node_list="dyn1-[1-10],dyn2-[1-10],g1",partition="dynamic",vm_size="Standard_NC80adis_H100_v5"} 0.0 -azslurm_partition_info{available_azure_quota="1",node_list="azslurm-exporter-gpu-1",partition="gpu",vm_size="Standard_NC80adis_H100_v5"} 1.0 -azslurm_partition_info{available_azure_quota="4",node_list="azslurm-exporter-hpc-[1-16]",partition="hpc",vm_size="Standard_F2s_v2"} 0.0 -azslurm_partition_info{available_azure_quota="4",node_list="azslurm-exporter-htc-[1-50]",partition="htc",vm_size="Standard_F2s_v2"} 4.0 -# HELP slurm_exporter_collect_duration_seconds Time spent collecting Slurm metrics -# TYPE slurm_exporter_collect_duration_seconds gauge -slurm_exporter_collect_duration_seconds 0.69551682472229 -# HELP slurm_exporter_last_collect_timestamp_seconds Timestamp of last successful metric collection -# TYPE slurm_exporter_last_collect_timestamp_seconds gauge -slurm_exporter_last_collect_timestamp_seconds 1.771529638582487e+09 \ No newline at end of file diff --git a/azure-slurm-exporter/current_dashboard_metrics.txt b/azure-slurm-exporter/current_dashboard_metrics.txt deleted file mode 100644 index d46f2f06..00000000 --- a/azure-slurm-exporter/current_dashboard_metrics.txt +++ /dev/null @@ -1,11 +0,0 @@ -azslurm_cluster_info -scontrol_partition_nodes_{state} -azslurm_partition_info -squeue_partition_jobs_{state} -squeue_job_nodes_allocated -sacct_jobs_total_one_month_by_state -sacct_jobs_total_six_months_by_state -sacct_jobs_total_one_week_by_state -sacct_partition_jobs_total_six_months_by_state_exit_code -sacct_partition_jobs_total_one_month_by_state_exit_code -sacct_partition_jobs_total_one_week_by_state_exit_code diff --git a/azure-slurm-exporter/exporter/__init__.py b/azure-slurm-exporter/exporter/__init__.py new file mode 100644 index 00000000..4ec2d904 --- /dev/null +++ b/azure-slurm-exporter/exporter/__init__.py @@ -0,0 +1,3 @@ +from exporter import main + +__all__ = ["main"] \ No newline at end of file diff --git a/azure-slurm-exporter/azslurm.py b/azure-slurm-exporter/exporter/azslurm.py similarity index 100% rename from azure-slurm-exporter/azslurm.py rename to azure-slurm-exporter/exporter/azslurm.py diff --git a/azure-slurm-exporter/exporter.py b/azure-slurm-exporter/exporter/exporter.py similarity index 100% rename from azure-slurm-exporter/exporter.py rename to azure-slurm-exporter/exporter/exporter.py index 91aa9293..4c57a907 100644 --- a/azure-slurm-exporter/exporter.py +++ b/azure-slurm-exporter/exporter/exporter.py @@ -187,9 +187,9 @@ async def start_http_server(self, host:str, port:int) -> web.AppRunner: async def main(): - if os.path.exists("exporter_logging.conf"): logging.config.fileConfig("exporter_logging.conf") + loop = asyncio.get_running_loop() stop_event = asyncio.Event() diff --git a/azure-slurm-exporter/jetpack.py b/azure-slurm-exporter/exporter/jetpack.py similarity index 100% rename from azure-slurm-exporter/jetpack.py rename to azure-slurm-exporter/exporter/jetpack.py diff --git a/azure-slurm-exporter/sacct.py b/azure-slurm-exporter/exporter/sacct.py similarity index 100% rename from azure-slurm-exporter/sacct.py rename to azure-slurm-exporter/exporter/sacct.py diff --git a/azure-slurm-exporter/sinfo.py b/azure-slurm-exporter/exporter/sinfo.py similarity index 100% rename from azure-slurm-exporter/sinfo.py rename to azure-slurm-exporter/exporter/sinfo.py diff --git a/azure-slurm-exporter/squeue.py b/azure-slurm-exporter/exporter/squeue.py similarity index 100% rename from azure-slurm-exporter/squeue.py rename to azure-slurm-exporter/exporter/squeue.py diff --git a/azure-slurm-exporter/util.py b/azure-slurm-exporter/exporter/util.py similarity index 100% rename from azure-slurm-exporter/util.py rename to azure-slurm-exporter/exporter/util.py diff --git a/azure-slurm-exporter/package.py b/azure-slurm-exporter/package.py new file mode 100644 index 00000000..242adf2b --- /dev/null +++ b/azure-slurm-exporter/package.py @@ -0,0 +1,104 @@ +import argparse +import configparser +import glob +import pip +import os +import shutil +import sys +import tarfile +import tempfile +from argparse import Namespace +from subprocess import check_call +from typing import Dict, List, Optional + +def build_sdist() -> str: + check_call([sys.executable, "setup.py", "sdist"]) + # sometimes this is azure-slurm, sometimes it is azure_slurm, depenends on the build system version. + sdists = glob.glob("dist/azure*slurm*exporter*.tar.gz") + assert len(sdists) == 1, f"Found %d sdist packages, expected 1 - see {os.path.abspath('dist/azure-slurm-exporter*.tar.gz')}" % len(sdists) + path = sdists[0] + fname = os.path.basename(path) + dest = os.path.join("libs", fname) + if os.path.exists(dest): + os.remove(dest) + shutil.move(path, dest) + return fname + +def execute() -> None: + + expected_cwd = os.path.abspath(os.path.dirname(__file__)) + os.chdir(expected_cwd) + + print("Running from", expected_cwd) + + if not os.path.exists("libs"): + os.makedirs("libs") + + parser = configparser.ConfigParser() + ini_path = os.path.abspath("../project.ini") + + with open(ini_path) as fr: + parser.read_file(fr) + + version = parser.get("project", "version") + if not version: + raise RuntimeError("Missing [project] -> version in {}".format(ini_path)) + + ret = [build_sdist()] + + if not os.path.exists("dist"): + os.makedirs("dist") + + tf = tarfile.TarFile.gzopen( + "dist/azure-slurm-exporter-pkg-{}.tar.gz".format(version), "w" + ) + + build_dir = tempfile.mkdtemp("azure-slurm-exporter") + + + def _add(name: str, path: Optional[str] = None, mode: Optional[int] = None) -> None: + path = path or name + tarinfo = tarfile.TarInfo("azure-slurm-exporter/" + name) + tarinfo.size = os.path.getsize(path) + tarinfo.mtime = int(os.path.getmtime(path)) + if mode: + tarinfo.mode = mode + + with open(path, "rb") as fr: + tf.addfile(tarinfo, fr) + + packages = [] + for dep in ret: + dep_path = os.path.abspath(os.path.join("libs", dep)) + _add("packages/" + dep, dep_path) + packages.append(dep_path) + mypip = shutil.which("pip3") + print("my pip", mypip) + check_call([mypip, "download"] + packages, cwd=build_dir) + + print("Using build dir", build_dir) + by_package: Dict[str, List[str]] = {} + for fil in os.listdir(build_dir): + toks = fil.split("-", 1) + package = toks[0] + if package not in by_package: + by_package[package] = [] + by_package[package].append(fil) + + for package, fils in by_package.items(): + + if len(fils) > 1: + print("WARNING: Ignoring duplicate package found:", package, fils) + assert False + + for fil in os.listdir(build_dir): + # Skip platform-specific or unnecessary packages + path = os.path.join(build_dir, fil) + _add("packages/" + fil, path) + + _add("exporter_logging.conf", "conf/exporter_logging.conf") + + + +if __name__ == "__main__": + execute() diff --git a/azure-slurm-exporter/package.sh b/azure-slurm-exporter/package.sh new file mode 100755 index 00000000..50be9dea --- /dev/null +++ b/azure-slurm-exporter/package.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -e + +if [ ! -e libs ]; then + mkdir libs +fi + +rm -f dist/* + +python3.11 package.py diff --git a/azure-slurm-exporter/setup.py b/azure-slurm-exporter/setup.py new file mode 100644 index 00000000..e4906b30 --- /dev/null +++ b/azure-slurm-exporter/setup.py @@ -0,0 +1,136 @@ +# test: ignore +import os +from subprocess import check_call +from typing import List + +from setuptools import find_packages, setup +from setuptools.command.test import Command +from setuptools.command.test import test as TestCommand # noqa: N812 + +__version__ = "4.0.6" +CWD = os.path.dirname(os.path.abspath(__file__)) + + +class PyTest(TestCommand): + def finalize_options(self) -> None: + TestCommand.finalize_options(self) + import os + + xml_out = os.path.join(".", "build", "test-results", "pytest.xml") + if not os.path.exists(os.path.dirname(xml_out)): + os.makedirs(os.path.dirname(xml_out)) + # -s is needed so py.test doesn't mess with stdin/stdout + self.test_args = ["-s", "test", "--junitxml=%s" % xml_out] + # needed for older setuptools to actually run this as a test + self.test_suite = True + + def run_tests(self) -> None: + # import here, cause outside the eggs aren't loaded + import sys + import pytest + + # run the tests, then the format checks. + errno = pytest.main(self.test_args) + if errno != 0: + sys.exit(errno) + + check_call( + ["black", "--check", "src", "test"], + cwd=CWD, + ) + check_call( + ["isort", "-c"], + cwd=os.path.join(CWD, "src"), + ) + check_call( + ["isort", "-c"], + cwd=os.path.join(CWD, "test"), + ) + + run_type_checking() + + sys.exit(errno) + + +class Formatter(Command): + user_options: List[str] = [] + + def initialize_options(self) -> None: + pass + + def finalize_options(self) -> None: + pass + + def run(self) -> None: + check_call( + ["black", "src", "test"], cwd=CWD, + ) + check_call( + ["isort", "-y"], + cwd=os.path.join(CWD, "src"), + ) + check_call( + ["isort", "-y"], + cwd=os.path.join(CWD, "test"), + ) + run_type_checking() + + +def run_type_checking() -> None: + check_call( + [ + "mypy", + "--ignore-missing-imports", + "--follow-imports=silent", + "--show-column-numbers", + "--disallow-untyped-defs", + os.path.join(CWD, "test"), + ] + ) + check_call( + [ + "mypy", + "--ignore-missing-imports", + "--follow-imports=silent", + "--show-column-numbers", + "--disallow-untyped-defs", + os.path.join(CWD, "src"), + ] + ) + + check_call(["flake8", "--ignore=E203,E231,F405,E501,W503", "src", "test", "setup.py"]) + + +class TypeChecking(Command): + user_options: List[str] = [] + + def initialize_options(self) -> None: + pass + + def finalize_options(self) -> None: + pass + + def run(self) -> None: + run_type_checking() + + +setup( + name="azure-slurm-exporter", + version=__version__, + packages=find_packages(), + #package_dir={"": "slurmcc"}, + package_data={ + "azure-slurm-exporter": [ + "BUILD_NUMBER", + "private-requirements.json", + "../NOTICE", + "../notices", + ] + }, + install_requires=["prometheus-client", "aiohttp"], + tests_require=["pytest==3.2.3"], + cmdclass={"test": PyTest, "format": Formatter, "types": TypeChecking}, + url="http://www.cyclecomputing.com", + maintainer="Cycle Computing", + maintainer_email="support@cyclecomputing.com", +) diff --git a/project.ini b/project.ini index 656ae380..2a8bc3f2 100644 --- a/project.ini +++ b/project.ini @@ -5,7 +5,7 @@ version = 4.0.6 type = scheduler [blobs] -Files = azure-slurm-pkg-4.0.6.tar.gz, azure-slurm-install-pkg-4.0.6.tar.gz +Files = azure-slurm-pkg-4.0.6.tar.gz, azure-slurm-install-pkg-4.0.6.tar.gz, azure-slurm-exporter-pkg-4.0.6.tar.gz [spec scheduler] run_list = role[slurm_scheduler_role] diff --git a/util/build.sh b/util/build.sh index 2e909aa2..7f1f35f0 100755 --- a/util/build.sh +++ b/util/build.sh @@ -36,3 +36,8 @@ cd $SOURCE/azure-slurm rm -f dist/* ./package.sh $LOCAL_SCALELIB mv dist/* ../blobs/ + +cd $SOURCE/azure-slurm-exporter +rm -f dist/* +./package.sh +mv dist/* ../blobs/ From 32a30915b57cf813e45902f1103286bb2594df78 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Tue, 3 Mar 2026 15:48:06 -0500 Subject: [PATCH 06/22] create azslurm-exporter systemd --- azure-slurm-exporter/exporter/__init__.py | 2 +- azure-slurm-exporter/install.sh | 188 ++++++++++++++++++ azure-slurm-exporter/package.py | 6 +- .../cluster-init/scripts/00-install.sh | 17 +- 4 files changed, 206 insertions(+), 7 deletions(-) create mode 100755 azure-slurm-exporter/install.sh diff --git a/azure-slurm-exporter/exporter/__init__.py b/azure-slurm-exporter/exporter/__init__.py index 4ec2d904..1f697c83 100644 --- a/azure-slurm-exporter/exporter/__init__.py +++ b/azure-slurm-exporter/exporter/__init__.py @@ -1,3 +1,3 @@ -from exporter import main +from .exporter import main __all__ = ["main"] \ No newline at end of file diff --git a/azure-slurm-exporter/install.sh b/azure-slurm-exporter/install.sh new file mode 100755 index 00000000..5ae15c99 --- /dev/null +++ b/azure-slurm-exporter/install.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# +set -e + +find_python3() { + export PATH=$(echo $PATH | sed -e 's/\/opt\/cycle\/jetpack\/system\/embedded\/bin://g' | sed -e 's/:\/opt\/cycle\/jetpack\/system\/embedded\/bin//g') + if [ ! -z $AZSLURM_PYTHON_PATH ]; then + echo $AZSLURM_PYTHON_PATH + return 0 + fi + for version in $( seq 11 20 ); do + which python3.$version + if [ $? == 0 ]; then + return 0 + fi + done + echo Could not find python3 version 3.11 >&2 + return 1 +} + +setup_venv() { + + set -e + + $PYTHON_PATH -c "import sys; sys.exit(0)" || (echo "$PYTHON_PATH is not a valid python3 executable. Please install python3.11 or higher." && exit 1) + $PYTHON_PATH -m pip --version > /dev/null || $PYTHON_PATH -m ensurepip + $PYTHON_PATH -m venv $VENV + + set +e + source $VENV/bin/activate + set -e + + # ensure wheel is installed + python3 -m pip install wheel + python3 -m pip install aiohttp + + # upgrade venv with packages from intallation + python3 -m pip install --upgrade --no-deps packages/* + + # Create exporter executable + # NOTE: dynamically generated due to the SCALELIB_LOG_USER and SCALELIB_LOG_GROUP + cat > $VENV/bin/azslurm-exporter <&1 > /dev/null || exit 1 +} + + + +setup_azslurm_exporter() { + cat > /etc/systemd/system/azslurm-exporter.service < /dev/null || echo slurm) + export SCALELIB_LOG_GROUP=$(jetpack config slurm.group.name 2>/dev/null || echo slurm) + # Set this globally before running main. + export PYTHON_PATH=$(find_python3) + export PATH=$PATH:/root/bin + + while (( "$#" )); do + case "$1" in + --no-jetpack) + NO_JETPACK=1 + shift + ;; + --help) + echo "Usage: $0 [--no-jetpack]" + exit 0 + ;; + -*|--*=) + echo "Unknown option $1" >&2 + exit 1 + ;; + *) + echo "Unknown option $1" >&2 + exit 1 + ;; + esac + done +} + +main() { + # create the venv and make sure azslurm is in the path + setup_venv + # setup the azslurmd but do not start it. + setup_azslurm_exporter +} + +require_root() { + if [ $(whoami) != root ]; then + echo "Please run as root" + exit 1 + fi +} + + +parse_args_set_variables() { + export SCHEDULER=slurm + export VENV=/opt/azurehpc/azslurm-exporter/venv + export INSTALL_DIR=$(dirname $VENV) + export NO_JETPACK=0 + # if jetpack doesn't exist or this is not defined, it will silently use slurm as default + export SCALELIB_LOG_USER=$(jetpack config slurm.user.name 2> /dev/null || echo slurm) + export SCALELIB_LOG_GROUP=$(jetpack config slurm.group.name 2>/dev/null || echo slurm) + # Set this globally before running main. + export PYTHON_PATH=$(find_python3) + export PATH=$PATH:/root/bin + + while (( "$#" )); do + case "$1" in + --no-jetpack) + NO_JETPACK=1 + shift + ;; + --help) + echo "Usage: $0 [--no-jetpack]" + exit 0 + ;; + -*|--*=) + echo "Unknown option $1" >&2 + exit 1 + ;; + *) + echo "Unknown option $1" >&2 + exit 1 + ;; + esac + done +} + +require_root +parse_args_set_variables $@ +main +echo Installation complete. diff --git a/azure-slurm-exporter/package.py b/azure-slurm-exporter/package.py index 242adf2b..fb5e3d32 100644 --- a/azure-slurm-exporter/package.py +++ b/azure-slurm-exporter/package.py @@ -93,11 +93,15 @@ def _add(name: str, path: Optional[str] = None, mode: Optional[int] = None) -> N for fil in os.listdir(build_dir): # Skip platform-specific or unnecessary packages + skip_packages = ["aiohttp", "frozenlist","multidict","propcache","yarl"] + if any(pkg in fil.lower() for pkg in skip_packages): + print(f"WARNING: Ignoring unnecessary package {fil}, platform specific or not needed.") + continue path = os.path.join(build_dir, fil) _add("packages/" + fil, path) _add("exporter_logging.conf", "conf/exporter_logging.conf") - + _add("install.sh", "install.sh", mode=os.stat("install.sh")[0]) if __name__ == "__main__": diff --git a/specs/scheduler/cluster-init/scripts/00-install.sh b/specs/scheduler/cluster-init/scripts/00-install.sh index 4a20e025..729f92d2 100644 --- a/specs/scheduler/cluster-init/scripts/00-install.sh +++ b/specs/scheduler/cluster-init/scripts/00-install.sh @@ -4,6 +4,7 @@ set -e do_install=$(jetpack config slurm.do_install True) install_pkg=$(jetpack config slurm.install_pkg azure-slurm-install-pkg-4.0.6.tar.gz) autoscale_pkg=$(jetpack config slurm.autoscale_pkg azure-slurm-pkg-4.0.6.tar.gz) +exporter_pkg=$(jetpack config slurm.autoscale_pkg azure-slurm-exporter-pkg-4.0.6.tar.gz) slurm_project_name=$(jetpack config slurm.project_name slurm) find_python3() { @@ -52,29 +53,29 @@ install_python3() { echo "Detected AlmaLinux. Installing Python 3.12..." >&2 yum install -y python3.12 python3.12-pyyaml PYTHON_BIN="/usr/bin/python3.12" - + elif [ "$OS" == "ubuntu" ] && [ "$VERSION_ID" == "22.04" ]; then echo "Detected Ubuntu 22.04. Installing Python 3.11..." >&2 apt update # We need python dev headers and systemd dev headers for same reaosn mentioned above. apt install -y python3.11 python3.11-venv python3-yaml PYTHON_BIN="/usr/bin/python3.11" - + elif [ "$OS" == "ubuntu" ] && [[ $VERSION =~ ^24\.* ]]; then echo "Detected Ubuntu 24. Installing Python 3.12..." >&2 apt update apt install -y python3.12 python3.12-venv python3-yaml PYTHON_BIN="/usr/bin/python3.12" - + elif [ "$OS" == "rhel" ]; then echo "Detected RHEL, using system python3..." >&2 PYTHON_BIN="/usr/bin/python3" - + elif [ "$OS" == "sle_hpc" ]; then echo "Detected SUSE, installing Python 3.11..." >&2 zypper install -y python311 python311-virtualenv python311-PyYAML PYTHON_BIN="/usr/bin/python3.11" - + else echo "Unsupported operating system: $OS $VERSION_ID" >&2 exit 1 @@ -101,4 +102,10 @@ tar xzf $autoscale_pkg cd azure-slurm AZSLURM_PYTHON_PATH=$PYTHON_BIN ./install.sh +rm -rf azure-slurm-exporter +jetpack download --project $slurm_project_name $exporter_pkg +tar xzf $exporter_pkg +cd azure-slurm-exporter +AZSLURM_PYTHON_PATH=$PYTHON_BIN ./install.sh + echo "installation complete. Run start-services scheduler|execute|login to start the slurm services." From 768b65476a6ec37ec51e3fa03681a077d34cdfab Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Tue, 3 Mar 2026 16:24:25 -0500 Subject: [PATCH 07/22] fix imports --- azure-slurm-exporter/exporter/azslurm.py | 4 ++-- azure-slurm-exporter/exporter/exporter.py | 10 +++++----- azure-slurm-exporter/exporter/jetpack.py | 4 ++-- azure-slurm-exporter/exporter/sacct.py | 4 ++-- azure-slurm-exporter/exporter/sinfo.py | 4 ++-- azure-slurm-exporter/exporter/squeue.py | 4 ++-- azure-slurm-exporter/setup.py | 1 + 7 files changed, 16 insertions(+), 15 deletions(-) diff --git a/azure-slurm-exporter/exporter/azslurm.py b/azure-slurm-exporter/exporter/azslurm.py index 52aea406..dc2f0e57 100644 --- a/azure-slurm-exporter/exporter/azslurm.py +++ b/azure-slurm-exporter/exporter/azslurm.py @@ -1,9 +1,9 @@ -from exporter import BaseCollector +from .exporter import BaseCollector from collections import namedtuple from prometheus_client import Gauge import json import logging -import util as util +import exporter.util as util import re from typing import List log = logging.getLogger(__name__) diff --git a/azure-slurm-exporter/exporter/exporter.py b/azure-slurm-exporter/exporter/exporter.py index 4c57a907..4b21f238 100644 --- a/azure-slurm-exporter/exporter/exporter.py +++ b/azure-slurm-exporter/exporter/exporter.py @@ -92,7 +92,7 @@ def initialize_collectors(self) -> None: - Jetpack: Collects cluster specs """ try: - from squeue import Squeue, SqueueNotAvailException + from exporter.squeue import Squeue, SqueueNotAvailException squeue = Squeue() squeue.initialize() except SqueueNotAvailException: @@ -101,7 +101,7 @@ def initialize_collectors(self) -> None: self.collectors.append(squeue) try: - from sacct import Sacct, SacctNotAvailException + from exporter.sacct import Sacct, SacctNotAvailException sacct = Sacct() sacct.initialize() except SacctNotAvailException: @@ -110,7 +110,7 @@ def initialize_collectors(self) -> None: self.collectors.append(sacct) try: - from sinfo import Sinfo, SinfoNotAvailException + from exporter.sinfo import Sinfo, SinfoNotAvailException sinfo = Sinfo() sinfo.initialize() except SinfoNotAvailException: @@ -119,7 +119,7 @@ def initialize_collectors(self) -> None: self.collectors.append(sinfo) try: - from azslurm import Azslurm, AzslurmNotAvailException + from exporter.azslurm import Azslurm, AzslurmNotAvailException azslurm = Azslurm() azslurm.initialize() except AzslurmNotAvailException: @@ -128,7 +128,7 @@ def initialize_collectors(self) -> None: self.collectors.append(azslurm) try: - from jetpack import Jetpack, JetpackNotAvailException + from exporter.jetpack import Jetpack, JetpackNotAvailException jetpack = Jetpack() jetpack.initialize() except JetpackNotAvailException: diff --git a/azure-slurm-exporter/exporter/jetpack.py b/azure-slurm-exporter/exporter/jetpack.py index 6571fcbd..bad74528 100644 --- a/azure-slurm-exporter/exporter/jetpack.py +++ b/azure-slurm-exporter/exporter/jetpack.py @@ -1,8 +1,8 @@ -from exporter import BaseCollector +from .exporter import BaseCollector from collections import namedtuple from prometheus_client import Gauge import logging -import util as util +import exporter.util as util from typing import List log = logging.getLogger(__name__) diff --git a/azure-slurm-exporter/exporter/sacct.py b/azure-slurm-exporter/exporter/sacct.py index 19582cf9..65c7c25e 100644 --- a/azure-slurm-exporter/exporter/sacct.py +++ b/azure-slurm-exporter/exporter/sacct.py @@ -1,9 +1,9 @@ -from exporter import BaseCollector +from .exporter import BaseCollector from collections import namedtuple from prometheus_client import Counter, disable_created_metrics from datetime import datetime, timedelta import logging -import util as util +import exporter.util as util from typing import List log = logging.getLogger(__name__) diff --git a/azure-slurm-exporter/exporter/sinfo.py b/azure-slurm-exporter/exporter/sinfo.py index ce8f90df..aa44809e 100644 --- a/azure-slurm-exporter/exporter/sinfo.py +++ b/azure-slurm-exporter/exporter/sinfo.py @@ -1,8 +1,8 @@ -from exporter import BaseCollector +from .exporter import BaseCollector from collections import namedtuple from prometheus_client import Gauge import logging -import util as util +import exporter.util as util from typing import List log = logging.getLogger(__name__) diff --git a/azure-slurm-exporter/exporter/squeue.py b/azure-slurm-exporter/exporter/squeue.py index 7b5b8046..46e03824 100644 --- a/azure-slurm-exporter/exporter/squeue.py +++ b/azure-slurm-exporter/exporter/squeue.py @@ -1,9 +1,9 @@ -from exporter import BaseCollector +from .exporter import BaseCollector from collections import namedtuple from prometheus_client import Gauge from dataclasses import dataclass, field import logging -import util +import exporter.util as util from typing import List # @dataclass # class SqueueMetrics: diff --git a/azure-slurm-exporter/setup.py b/azure-slurm-exporter/setup.py index e4906b30..ebc799de 100644 --- a/azure-slurm-exporter/setup.py +++ b/azure-slurm-exporter/setup.py @@ -129,6 +129,7 @@ def run(self) -> None: }, install_requires=["prometheus-client", "aiohttp"], tests_require=["pytest==3.2.3"], + cmdclass={"test": PyTest, "format": Formatter, "types": TypeChecking}, url="http://www.cyclecomputing.com", maintainer="Cycle Computing", From 1e3fb63b0eeee41fd6a75067b9b24995f371361e Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Tue, 3 Mar 2026 16:26:29 -0500 Subject: [PATCH 08/22] run async --- azure-slurm-exporter/install.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/azure-slurm-exporter/install.sh b/azure-slurm-exporter/install.sh index 5ae15c99..48f4998a 100755 --- a/azure-slurm-exporter/install.sh +++ b/azure-slurm-exporter/install.sh @@ -52,7 +52,8 @@ if "SCALELIB_LOG_GROUP" not in os.environ: os.environ["SCALELIB_LOG_GROUP"] = "$SCALELIB_LOG_GROUP" from exporter import main -main() +import asyncio +asyncio.run(main()) EOF @@ -66,7 +67,10 @@ EOF azslurm-exporter -h 2>&1 > /dev/null || exit 1 } - +setup_install_dir() { + mkdir -p $INSTALL_DIR/logs + cp exporter-logging.conf $INSTALL_DIR/ +} setup_azslurm_exporter() { cat > /etc/systemd/system/azslurm-exporter.service < Date: Tue, 3 Mar 2026 16:58:41 -0500 Subject: [PATCH 09/22] start azslurm-exporter if monitoring enabled --- azure-slurm-exporter/install.sh | 62 +----------------- azure-slurm-install/start-services.sh | 65 +++++-------------- .../cluster-init/scripts/00-install.sh | 13 ++-- 3 files changed, 28 insertions(+), 112 deletions(-) diff --git a/azure-slurm-exporter/install.sh b/azure-slurm-exporter/install.sh index 48f4998a..ccdcbf50 100755 --- a/azure-slurm-exporter/install.sh +++ b/azure-slurm-exporter/install.sh @@ -40,17 +40,9 @@ setup_venv() { python3 -m pip install --upgrade --no-deps packages/* # Create exporter executable - # NOTE: dynamically generated due to the SCALELIB_LOG_USER and SCALELIB_LOG_GROUP cat > $VENV/bin/azslurm-exporter <&1 > /dev/null || exit 1 } setup_install_dir() { - mkdir -p $INSTALL_DIR/logs - cp exporter-logging.conf $INSTALL_DIR/ + cp exporter_logging.conf $INSTALL_DIR/ } setup_azslurm_exporter() { @@ -92,10 +81,6 @@ EOF systemctl enable azslurm-exporter } -no_jetpack() { - echo "--no-jetpack is set. Please run $INSTALL_DIR/init-config.sh then $INSTALL_DIR/post-install.sh." -} - require_root() { if [ $(whoami) != root ]; then echo "Please run as root" @@ -107,22 +92,14 @@ parse_args_set_variables() { export SCHEDULER=slurm export VENV=/opt/azurehpc/azslurm-exporter/venv export INSTALL_DIR=$(dirname $VENV) - export NO_JETPACK=0 - # if jetpack doesn't exist or this is not defined, it will silently use slurm as default - export SCALELIB_LOG_USER=$(jetpack config slurm.user.name 2> /dev/null || echo slurm) - export SCALELIB_LOG_GROUP=$(jetpack config slurm.group.name 2>/dev/null || echo slurm) # Set this globally before running main. export PYTHON_PATH=$(find_python3) export PATH=$PATH:/root/bin while (( "$#" )); do case "$1" in - --no-jetpack) - NO_JETPACK=1 - shift - ;; --help) - echo "Usage: $0 [--no-jetpack]" + echo "Usage: $0" exit 0 ;; -*|--*=) @@ -152,41 +129,6 @@ require_root() { fi } - -parse_args_set_variables() { - export SCHEDULER=slurm - export VENV=/opt/azurehpc/azslurm-exporter/venv - export INSTALL_DIR=$(dirname $VENV) - export NO_JETPACK=0 - # if jetpack doesn't exist or this is not defined, it will silently use slurm as default - export SCALELIB_LOG_USER=$(jetpack config slurm.user.name 2> /dev/null || echo slurm) - export SCALELIB_LOG_GROUP=$(jetpack config slurm.group.name 2>/dev/null || echo slurm) - # Set this globally before running main. - export PYTHON_PATH=$(find_python3) - export PATH=$PATH:/root/bin - - while (( "$#" )); do - case "$1" in - --no-jetpack) - NO_JETPACK=1 - shift - ;; - --help) - echo "Usage: $0 [--no-jetpack]" - exit 0 - ;; - -*|--*=) - echo "Unknown option $1" >&2 - exit 1 - ;; - *) - echo "Unknown option $1" >&2 - exit 1 - ;; - esac - done -} - require_root parse_args_set_variables $@ main diff --git a/azure-slurm-install/start-services.sh b/azure-slurm-install/start-services.sh index 7d8626b7..a5eba5b0 100644 --- a/azure-slurm-install/start-services.sh +++ b/azure-slurm-install/start-services.sh @@ -125,9 +125,9 @@ run_slurmrestd() { /opt/cycle/jetpack/bin/jetpack log "slurmrestd failed to start" --level=warn --priority=medium exit 0 fi - # start slurm_exporter if monitoring is enabled and slurmrestd is running + # start azslurm-exporter if monitoring is enabled and slurmrestd is running if [[ "$monitoring_enabled" == "True" ]]; then - run_slurm_exporter + run_azslurm_exporter fi } @@ -143,13 +143,13 @@ reload_prom_config(){ kill -HUP $PROM_PID else echo "Prometheus process not found, unable to reload configuration" - fi + fi } -run_slurm_exporter() { - # Run Slurm Exporter in a container +run_azslurm_exporter() { + # start azslurm-exporter systemd if [[ "$role" != "scheduler" ]]; then - echo "Slurm Exporter can only be run on the scheduler node, skipping setup." + echo "AzSlurm Exporter can only be run on the scheduler node, skipping setup." return 0 fi @@ -160,54 +160,25 @@ run_slurm_exporter() { echo "This is not the primary scheduler, skipping slurm_exporter setup." return 0 fi - - SLURM_EXPORTER_PORT=9200 - SLURM_EXPORTER_IMAGE_NAME="ghcr.io/slinkyproject/slurm-exporter:0.3.0" - # Try to get the token, retry up to 3 times - unset SLURM_JWT - for attempt in 1 2 3; do - export $(scontrol token username="slurmrestd" lifespan=infinite) - if [ -n "$SLURM_JWT" ]; then - break - fi - echo "Attempt $attempt: Failed to get SLURM_JWT token, retrying in 5 seconds..." - scontrol reconfigure - sleep 5 - done - if [ -z "$SLURM_JWT" ]; then - echo "Failed to get SLURM_JWT token after 3 attempts." - echo "Check slurmctld status, slurm.conf JWT configuration, and logs for errors." - /opt/cycle/jetpack/bin/jetpack log "Failed to get SLURM_JWT token after 3 attempts, disabling slurm_exporter setup." --level=warn --priority=medium - return 0 - fi - # Check if the container is already running, and if so, stop it - if [ "$(docker ps -q -f ancestor=$SLURM_EXPORTER_IMAGE_NAME)" ]; then - echo "Slurm Exporter is already running, stopping it..." - docker stop $(docker ps -q -f ancestor=$SLURM_EXPORTER_IMAGE_NAME) - fi - - # Run the Slurm Exporter container, expose the port so prometheus can scrape it. Redirect the host.docker.internal to the host gateway == localhost - docker run -v /var:/var -e SLURM_JWT=${SLURM_JWT} -d --restart always -p ${SLURM_EXPORTER_PORT}:8080 --add-host=host.docker.internal:host-gateway $SLURM_EXPORTER_IMAGE_NAME -server http://host.docker.internal:6820 -cache-freq 10s - - # Check if the container is running - if [ "$(docker ps -q -f ancestor=$SLURM_EXPORTER_IMAGE_NAME)" ]; then - echo "Slurm Exporter is running" - else - echo "Slurm Exporter is not running" - /opt/cycle/jetpack/bin/jetpack log "Slurm Exporter container failed to start" --level=warn --priority=medium + AZSLURM_EXPORTER_PORT=9101 + systemctl start azslurm-exporter + systemctl status azslurm-exporter --no-pager > /dev/null + if [ $? != 0 ]; then + echo "AzSlurm Exporter is not running" + /opt/cycle/jetpack/bin/jetpack log "AzSlurm Exporter systemd failed to start" --level=warn --priority=medium return 0 # do not fail the slurm startup if exporter fails fi reload_prom_config - + sleep 20 - if curl -s http://localhost:${SLURM_EXPORTER_PORT}/metrics | grep -q "slurm_nodes_total"; then + if curl -s http://localhost:${AZSLURM_EXPORTER_PORT}/metrics | grep -q "jetpack_cluster_info"; then echo "Slurm Exporter metrics are available" else - echo "Slurm Exporter metrics are not available" + echo "AzSlurm Exporter metrics are not available" /opt/cycle/jetpack/bin/jetpack log "Slurm Exporter metrics are not available" --level=warn --priority=medium - fi + fi } ensure_enroot_dir() { @@ -236,7 +207,7 @@ ensure_enroot_dir() { chmod 1777 "$BASE_DIR" } -{ +{ if [ "$1" == "" ]; then echo "Usage: $0 [scheduler|execute|login]" exit 1 @@ -284,7 +255,7 @@ ensure_enroot_dir() { # lastly - the scheduler use_accounting=$(jetpack config slurm.accounting.enabled False) - if [ "$use_accounting" == "True" ]; then + if [ "$use_accounting" == "True" ]; then run_slurmdbd else echo "Warning: slurm.accounting.enabled=${use_accounting}: skipping slurmdbd" >&2 diff --git a/specs/scheduler/cluster-init/scripts/00-install.sh b/specs/scheduler/cluster-init/scripts/00-install.sh index 729f92d2..7036c0ff 100644 --- a/specs/scheduler/cluster-init/scripts/00-install.sh +++ b/specs/scheduler/cluster-init/scripts/00-install.sh @@ -6,6 +6,7 @@ install_pkg=$(jetpack config slurm.install_pkg azure-slurm-install-pkg-4.0.6.tar autoscale_pkg=$(jetpack config slurm.autoscale_pkg azure-slurm-pkg-4.0.6.tar.gz) exporter_pkg=$(jetpack config slurm.autoscale_pkg azure-slurm-exporter-pkg-4.0.6.tar.gz) slurm_project_name=$(jetpack config slurm.project_name slurm) +monitoring_enabled=$(jetpack config cyclecloud.monitoring.enabled False) find_python3() { export PATH=$(echo $PATH | sed -e 's/\/opt\/cycle\/jetpack\/system\/embedded\/bin://g' | sed -e 's/:\/opt\/cycle\/jetpack\/system\/embedded\/bin//g') @@ -102,10 +103,12 @@ tar xzf $autoscale_pkg cd azure-slurm AZSLURM_PYTHON_PATH=$PYTHON_BIN ./install.sh -rm -rf azure-slurm-exporter -jetpack download --project $slurm_project_name $exporter_pkg -tar xzf $exporter_pkg -cd azure-slurm-exporter -AZSLURM_PYTHON_PATH=$PYTHON_BIN ./install.sh +if [[ "$monitoring_enabled" == "True" ]]; then + rm -rf azure-slurm-exporter + jetpack download --project $slurm_project_name $exporter_pkg + tar xzf $exporter_pkg + cd azure-slurm-exporter + AZSLURM_PYTHON_PATH=$PYTHON_BIN ./install.sh +fi echo "installation complete. Run start-services scheduler|execute|login to start the slurm services." From e08192076d70fdc6ff8b2e3314b92ea23404f296 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Wed, 4 Mar 2026 10:10:32 -0500 Subject: [PATCH 10/22] add dashboards --- azure-slurm-exporter/dashboards/aszlsurm.json | 2818 +++++++++++++++++ .../dashboards/failed_jobs.json | 420 +++ 2 files changed, 3238 insertions(+) create mode 100644 azure-slurm-exporter/dashboards/aszlsurm.json create mode 100644 azure-slurm-exporter/dashboards/failed_jobs.json diff --git a/azure-slurm-exporter/dashboards/aszlsurm.json b/azure-slurm-exporter/dashboards/aszlsurm.json new file mode 100644 index 00000000..30b024bc --- /dev/null +++ b/azure-slurm-exporter/dashboards/aszlsurm.json @@ -0,0 +1,2818 @@ +{ + "annotations": { + "list": [ + { + "$$hashKey": "object:1345", + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Slurm Cluster Mission Control Dashboard", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 2, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1040, + "panels": [], + "title": "Cluster Specs", + "type": "row" + }, + { + "datasource": { + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 10, + "x": 0, + "y": 1 + }, + "id": 1041, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "editorMode": "code", + "exemplar": false, + "expr": "jetpack_cluster_info{cluster=\"$cluster\"}", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "A" + } + ], + "title": "Cluster Specs", + "transformations": [ + { + "id": "labelsToFields", + "options": { + "keepLabels": [ + "subscription", + "cluster", + "region" + ], + "mode": "rows" + } + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "label" + } + ] + } + }, + { + "id": "organize", + "options": { + "excludeByName": {}, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "label": "Spec", + "value": "Value" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 10, + "y": 1 + }, + "id": 1042, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value_and_name", + "wideLayout": false + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum by (partition)(scontrol_partition_nodes{cluster=\"$cluster\",partition=~\"$partition\"})", + "hide": true, + "instant": false, + "legendFormat": "{{partition}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum by (cluster)(sinfo_partition_nodes_state{cluster=\"$cluster\"})", + "hide": false, + "instant": false, + "interval": "", + "legendFormat": "Total Slurm Nodes", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum by (partition)(sinfo_partition_nodes_state{cluster=\"$cluster\",partition=~\"$partition\"})", + "hide": false, + "instant": false, + "legendFormat": "{{partition}}", + "range": true, + "refId": "C" + } + ], + "title": "Total Nodes by Partition $partition", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false, + "minWidth": 150 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 18, + "x": 0, + "y": 9 + }, + "id": 1043, + "options": { + "cellHeight": "lg", + "footer": { + "countRows": false, + "enablePagination": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "frameIndex": 1, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "nodelist" + } + ] + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "azslurm_partition_info{cluster=\"$cluster\",partition=~\"$partition\"}", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "A" + } + ], + "title": "Partition Specs", + "transformations": [ + { + "id": "timeSeriesTable", + "options": { + "A": { + "stat": "lastNotNull", + "timeField": "Time" + } + } + }, + { + "id": "labelsToFields", + "options": { + "mode": "columns" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "cluster": true, + "instance": true, + "job": true, + "physical_host": true, + "subscription": true + }, + "includeByName": {}, + "indexByName": { + "Trend #A": 9, + "__name__": 0, + "azure_count": 10, + "cluster": 1, + "instance": 2, + "job": 3, + "nodelist": 8, + "partition": 4, + "physical_host": 5, + "subscription": 6, + "vm_size": 7 + }, + "renameByName": { + "Metric": "", + "Trend #A": "Available Count", + "available_azure_quota": "Available Azure Quota", + "azure_count": "Available Azure Count", + "node_list": "Node List", + "partition": "Partition", + "vm_size": "VM Size" + } + } + } + ], + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 1027, + "panels": [], + "title": "Cluster Overview for partion $partition", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Running Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Configuring Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Completed Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Cancelled Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Node Failed Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failed Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 9, + "x": 0, + "y": 19 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "last" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "text": {}, + "textMode": "auto", + "wideLayout": false + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (state)(squeue_partition_jobs_state{cluster=\"$cluster\",partition=~\"$partition\"})", + "hide": false, + "instant": true, + "legendFormat": "{{state}}", + "range": false, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (cluster)(label_replace(squeue_partition_jobs_state{cluster=\"$cluster\",partition=~\"$partition\"}, \"total\", \"Submitted Jobs\", \"\", \"\"))", + "hide": false, + "instant": true, + "legendFormat": "Submitted Jobs", + "range": false, + "refId": "C" + } + ], + "title": "squeue snapshot", + "transformations": [ + { + "disabled": true, + "filter": { + "id": "byRefId", + "options": "/^(?:F)$/" + }, + "id": "filterFieldsByName", + "options": { + "byVariable": true, + "include": { + "variable": "$partition" + } + }, + "topic": "annotations" + } + ], + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 1, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Running Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Configuring Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Completed Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Cancelled Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Node Failed Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Submitted Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Jobs in Queue" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failed Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "running" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "configuring" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "completing" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "pending" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "submitted" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "text", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 16, + "w": 10, + "x": 9, + "y": 19 + }, + "id": 1037, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum by (state)(squeue_partition_jobs_state{cluster=\"$cluster\",partition=~\"$partition\"})", + "hide": false, + "instant": false, + "legendFormat": "{{state}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum by (cluster)(label_replace(squeue_partition_jobs_state{cluster=\"$cluster\",partition=~\"$partition\"}, \"total\", \"Submitted Jobs\", \"\", \"\"))", + "hide": false, + "instant": false, + "legendFormat": "submitted", + "range": true, + "refId": "A" + } + ], + "title": "Job Queue Overview", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "description": "#of of allocated nodes over all powered up nodes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "green", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 19, + "y": 19 + }, + "id": 1049, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "(\r\n sum(sinfo_partition_nodes_state{cluster=\"$cluster\", partition=~\"$partition\", state=~\"allocated|mixed|draining\"})\r\n /\r\n (\r\n sum(sinfo_partition_nodes_state{cluster=\"$cluster\", partition=~\"$partition\"})\r\n -\r\n sum(sinfo_partition_nodes_state{cluster=\"$cluster\", partition=~\"$partition\", state=~\"powered_off\"})\r\n )\r\n) * 100", + "hide": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "C" + } + ], + "title": "Active Cluster Utilization %", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "description": "#of of allocated nodes over all available nodes", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "green", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 19, + "y": 26 + }, + "id": 1050, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "(\r\n sum(sinfo_partition_nodes_state{cluster=\"$cluster\", partition=~\"$partition\", state=~\"allocated|mixed|draining\"})\r\n /\r\n (\r\n sum(sinfo_partition_nodes_state{cluster=\"$cluster\", partition=~\"$partition\"})\r\n\r\n )\r\n) * 100", + "hide": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Overall Cluster Utilization %", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 9, + "x": 0, + "y": 27 + }, + "id": 1047, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "frameIndex": 3, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Number of Allocated Nodes" + } + ] + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "topk(10,squeue_job_nodes_allocated{cluster=\"$cluster\",partition=~\"$partition\"})", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "A" + } + ], + "title": "Current Running Jobs by Node Allocation", + "transformations": [ + { + "id": "timeSeriesTable", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "__name__": true, + "cluster": true, + "instance": true, + "job": true, + "physical_host": true, + "state": true, + "subscription": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "Trend #A": "Number of Allocated Nodes" + } + } + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "Number of Allocated Nodes" + } + ] + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "fieldMinMax": false, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "IDLE" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ALLOCATED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DRAINING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "MAINTENANCE" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "super-light-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "UNKNOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#ff00f6", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Total" + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": false, + "viz": true + } + }, + { + "id": "color", + "value": { + "fixedColor": "transparent", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "POWERING UP" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "POWERED DOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#767472", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "MIXED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DRAIN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DRAINED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "RESERVED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "powered_off" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#918f8fa6", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "planned_backfill" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "allocated" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "n/a" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "transparent", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "mixed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 9, + "x": 0, + "y": 35 + }, + "id": 1035, + "options": { + "displayLabels": [ + "percent", + "value", + "name" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Value", + "sortDesc": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": true, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (state)(sinfo_partition_nodes_state{cluster=\"$cluster\",partition=~\"$partition\"})", + "hide": false, + "instant": true, + "legendFormat": "{{state}}", + "range": false, + "refId": "D" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (cluster)(sinfo_partition_nodes_state{cluster=\"$cluster\",partition=~\"$partition\"})", + "hide": false, + "instant": true, + "legendFormat": "Total", + "range": false, + "refId": "A" + } + ], + "title": "Live: Node status", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 37, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 2, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "IDLE" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ALLOCATED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DRAINING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "MAINTENANCE" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "super-light-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "UNKNOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#ff00f6", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "TOTAL" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "transparent", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "POWERED DOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "UNRESPONSIVE" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "POWERED UP" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "POWERING UP" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "TOTAL" + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": false, + "viz": true + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "MIXED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DRAIN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DRAINED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "RESERVED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "FAIL" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "allocated" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "idle" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "powering_up" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "completing" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "planned_backfill" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "mixed" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "n/a" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "transparent", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 18, + "w": 10, + "x": 9, + "y": 35 + }, + "id": 1034, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "sortBy": "Last *", + "sortDesc": true + }, + "tooltip": { + "hideZeros": true, + "mode": "multi", + "sort": "asc" + } + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum by (state)(sinfo_partition_nodes_state{cluster=\"$cluster\",partition=~\"$partition\"})", + "hide": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Live: Node status", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "IDLE" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ALLOCATED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DRAINING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "MAINTENANCE" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "UNKNOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#ff00f6", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "TOTAL" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "transparent", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "POWERING UP" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "POWERED DOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#767472", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "MIXED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DRAIN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "partition" + }, + "properties": [ + { + "id": "custom.width", + "value": 94 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "nodelist" + }, + "properties": [ + { + "id": "custom.width", + "value": 229 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "reason" + }, + "properties": [ + { + "id": "custom.width", + "value": 96 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "node_list" + }, + "properties": [ + { + "id": "custom.width", + "value": 169 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "state" + }, + "properties": [ + { + "id": "custom.width", + "value": 122 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 9, + "x": 0, + "y": 45 + }, + "id": 1051, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "frameIndex": 2, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "node_list" + } + ] + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sinfo_partition_nodes_state{cluster=\"$cluster\",partition=~\"$partition\"}", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "B" + } + ], + "title": "Nodelist by State", + "transformations": [ + { + "id": "timeSeriesTable", + "options": {} + }, + { + "id": "labelsToFields", + "options": { + "mode": "columns" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "__name__": true, + "cluster": true, + "instance": true, + "job": true, + "physical_host": true, + "subscription": true + }, + "includeByName": {}, + "indexByName": { + "Trend #A": 10, + "__name__": 1, + "cluster": 2, + "instance": 3, + "job": 4, + "node_list": 6, + "partition": 5, + "physical_host": 7, + "reason": 8, + "state": 0, + "subscription": 9 + }, + "renameByName": { + "Trend #A": "count", + "Trend #B": "count" + } + } + } + ], + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 53 + }, + "id": 1044, + "panels": [], + "title": "Total Finished Jobs for Partition $partition", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Running Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Configuring Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Completed Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Cancelled Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Node Failed Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Total Finished Jobs" + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": false, + "viz": true + } + }, + { + "id": "color", + "value": { + "fixedColor": "transparent", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failed Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Failed Jobs in last 6 months", + "url": "/d/cff0f6w0qqwaoa/failed-jobs-dashboard?orgId=1&from=${__from}&to=${__to}&timezone=browser&${promDatasource:queryparam}&${partition:queryparam}&${cluster:queryparam}&viewPanel=panel-1050" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Timed Out Jobs" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 10, + "x": 0, + "y": 54 + }, + "id": 1039, + "options": { + "displayLabels": [ + "name", + "value" + ], + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "values": [ + "value", + "percent" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (state)(sacct_terminal_jobs_total{cluster=\"$cluster\",partition=~\"$partition\",state=\"completed\"})", + "hide": false, + "instant": false, + "legendFormat": "Completed Jobs", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum by (failed)(label_replace(sacct_terminal_jobs_total{cluster=\"$cluster\",partition=~\"$partition\",state!=\"completed\"},\"failed\",\"Failed Jobs\",\"\",\"\"))", + "hide": false, + "instant": false, + "legendFormat": "Failed Jobs", + "range": true, + "refId": "D" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum by (cluster)(sacct_terminal_jobs_total{cluster=\"$cluster\",partition=~\"$partition\"})", + "hide": false, + "instant": false, + "legendFormat": "Total Finished Jobs", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum by (state)(increase(sacct_terminal_jobs_total{cluster=\"$cluster\",partition=~\"$partition\",state=\"timeout\"}[$__range]))", + "hide": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(increase(sacct_terminal_jobs_total{cluster=\"$cluster\",partition=~\"$partition\", state=\"completed\"}[$__range]))", + "hide": true, + "instant": false, + "legendFormat": "Total Finished Jobs", + "range": true, + "refId": "E" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sacct_terminal_jobs_total{cluster=\"$cluster\",partition=~\"$partition\",state=\"timeout\"}", + "hide": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "F" + } + ], + "title": "Total Finished Jobs", + "type": "piechart" + } + ], + "preload": false, + "refresh": "5s", + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [ + { + "current": { + "text": "cbbe2034-c78b-4e9b-89b4-8b78530247e5", + "value": "cbbe2034-c78b-4e9b-89b4-8b78530247e5" + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(azslurm_cluster_info,subscription)", + "includeAll": false, + "label": "Subscription", + "name": "Subscription", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(azslurm_cluster_info,subscription)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "current": { + "text": "Managed_Prometheus_ccw-mon-jwna6fuhty7ho", + "value": "ccw-mon-jwna6fuhty7ho" + }, + "includeAll": false, + "label": "Prometheus Data Source", + "name": "promDatasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "text": "All", + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(squeue_partition_jobs_running, partition)", + "includeAll": true, + "multi": true, + "name": "partition", + "options": [], + "query": { + "qryType": 5, + "query": "label_values(squeue_partition_jobs_running, partition)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "current": { + "text": "azcyclecloudwesteu-rg/u24-rc", + "value": "azcyclecloudwesteu-rg/u24-rc" + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(node_cpu_seconds_total, cluster)", + "name": "cluster", + "options": [], + "query": { + "qryType": 5, + "query": "label_values(node_cpu_seconds_total, cluster)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": "2025-09-05", + "value": "2025-09-05" + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(sacct_jobs_total_six_months_completed, start_date)", + "hide": 2, + "includeAll": false, + "name": "six_month_date", + "options": [], + "query": { + "qryType": 5, + "query": "label_values(sacct_jobs_total_six_months_completed, start_date)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "", + "sort": 4, + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": "2026-02-25", + "value": "2026-02-25" + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(sacct_jobs_total_one_week_completed, start_date)", + "hide": 2, + "includeAll": false, + "name": "one_week_date", + "options": [], + "query": { + "qryType": 5, + "query": "label_values(sacct_jobs_total_one_week_completed, start_date)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "", + "sort": 4, + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": "2026-02-02", + "value": "2026-02-02" + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(sacct_jobs_total_one_month_completed, start_date)", + "hide": 2, + "includeAll": false, + "name": "one_month_date", + "options": [], + "query": { + "qryType": 5, + "query": "label_values(sacct_jobs_total_one_month_completed, start_date)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "", + "sort": 4, + "type": "query" + } + ] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "AzSlurm Dashboard", + "uid": "aff0elgh9i0hsa", + "version": 1 +} \ No newline at end of file diff --git a/azure-slurm-exporter/dashboards/failed_jobs.json b/azure-slurm-exporter/dashboards/failed_jobs.json new file mode 100644 index 00000000..b65f2a7c --- /dev/null +++ b/azure-slurm-exporter/dashboards/failed_jobs.json @@ -0,0 +1,420 @@ +{ + "annotations": { + "list": [ + { + "$$hashKey": "object:1345", + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Failed Jobs Overview", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1047, + "panels": [], + "title": "Failed Jobs", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false, + "minWidth": 150 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 16, + "x": 0, + "y": 1 + }, + "id": 1050, + "options": { + "cellHeight": "lg", + "footer": { + "countRows": false, + "enablePagination": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "frameIndex": 1, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sacct_terminal_jobs_total{cluster=\"$cluster\",partition=~\"$partition\", state!=\"completed\"}", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "A" + } + ], + "title": "Failed Jobs by Exit Code", + "transformations": [ + { + "id": "timeSeriesTable", + "options": {} + }, + { + "disabled": true, + "id": "labelsToFields", + "options": { + "keepLabels": [ + "exit_code", + "partition", + "state" + ], + "mode": "columns" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "__name__": true, + "cluster": true, + "instance": true, + "job": true, + "physical_host": true, + "start_datetime hpc": true, + "subscription": true + }, + "includeByName": {}, + "indexByName": { + "Trend #A": 3, + "__name__": 0, + "cluster": 1, + "exit_code": 5, + "instance": 6, + "job": 7, + "partition": 2, + "physical_host": 8, + "start_datetime": 9, + "state": 4, + "subscription": 10 + }, + "renameByName": { + "Trend #A": "# of Failed Jobs" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 16, + "x": 0, + "y": 12 + }, + "id": 1051, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.9", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum(floor(increase(sacct_terminal_jobs_total{cluster=\"$cluster\",partition=~\"$partition\"}[$__range])))", + "hide": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "editorMode": "code", + "expr": "sum(sacct_terminal_jobs_total{cluster=\"$cluster\",partition=~\"$partition\", state!=\"completed\"})", + "hide": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B" + } + ], + "title": "Failed Jobs by Exit Code", + "type": "stat" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [ + { + "current": { + "text": "cbbe2034-c78b-4e9b-89b4-8b78530247e5", + "value": "cbbe2034-c78b-4e9b-89b4-8b78530247e5" + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(azslurm_cluster_info,subscription)", + "includeAll": false, + "label": "Subscription", + "name": "Subscription", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(azslurm_cluster_info,subscription)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "current": { + "text": "Managed_Prometheus_ccw-mon-jwna6fuhty7ho", + "value": "ccw-mon-jwna6fuhty7ho" + }, + "includeAll": false, + "label": "Prometheus Data Source", + "name": "promDatasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "text": "All", + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(squeue_partition_jobs_running, partition)", + "includeAll": true, + "multi": true, + "name": "partition", + "options": [], + "query": { + "qryType": 5, + "query": "label_values(squeue_partition_jobs_running, partition)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "current": { + "text": "azcyclecloudwesteu-rg/u24-rc", + "value": "azcyclecloudwesteu-rg/u24-rc" + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(node_cpu_seconds_total, cluster)", + "name": "cluster", + "options": [], + "query": { + "qryType": 5, + "query": "label_values(node_cpu_seconds_total, cluster)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": "2025-09-04", + "value": "2025-09-04" + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(sacct_jobs_total_six_months_completed, start_date)", + "hide": 2, + "includeAll": false, + "name": "six_month_date", + "options": [], + "query": { + "qryType": 5, + "query": "label_values(sacct_jobs_total_six_months_completed, start_date)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": "2026-02-24", + "value": "2026-02-24" + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(sacct_jobs_total_one_week_completed, start_date)", + "hide": 2, + "includeAll": false, + "name": "one_week_date", + "options": [], + "query": { + "qryType": 5, + "query": "label_values(sacct_jobs_total_one_week_completed, start_date)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": "2026-02-01", + "value": "2026-02-01" + }, + "datasource": { + "type": "prometheus", + "uid": "${promDatasource}" + }, + "definition": "label_values(sacct_jobs_total_one_month_completed, start_date)", + "hide": 2, + "includeAll": false, + "name": "one_month_date", + "options": [], + "query": { + "qryType": 5, + "query": "label_values(sacct_jobs_total_one_month_completed, start_date)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "", + "type": "query" + } + ] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Failed Jobs Dashboard", + "uid": "cff0f6w0qqwaoa", + "version": 1 +} \ No newline at end of file From 5e50f50567217fbd473999e4ec020721537a03dc Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Wed, 4 Mar 2026 10:15:02 -0500 Subject: [PATCH 11/22] fix logging --- azure-slurm-exporter/conf/exporter_logging.conf | 2 +- azure-slurm-exporter/exporter/exporter.py | 4 ++-- azure-slurm-exporter/install.sh | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/azure-slurm-exporter/conf/exporter_logging.conf b/azure-slurm-exporter/conf/exporter_logging.conf index 944f8a3d..e8ce8691 100644 --- a/azure-slurm-exporter/conf/exporter_logging.conf +++ b/azure-slurm-exporter/conf/exporter_logging.conf @@ -15,7 +15,7 @@ handlers=consoleHandler, fileHandler class=logging.handlers.RotatingFileHandler level=DEBUG formatter=simpleFormatter -args=("/var/log/azslurm-exporter.log", "a", 1024 * 1024 * 5, 5) +args=("/opt/azurehpc/azslurm-exporter/logs/azslurm-exporter.log", "a", 1024 * 1024 * 5, 5) [handler_consoleHandler] class=StreamHandler diff --git a/azure-slurm-exporter/exporter/exporter.py b/azure-slurm-exporter/exporter/exporter.py index 4b21f238..715ebc4d 100644 --- a/azure-slurm-exporter/exporter/exporter.py +++ b/azure-slurm-exporter/exporter/exporter.py @@ -187,8 +187,8 @@ async def start_http_server(self, host:str, port:int) -> web.AppRunner: async def main(): - if os.path.exists("exporter_logging.conf"): - logging.config.fileConfig("exporter_logging.conf") + if os.path.exists("/opt/azurehpc/azslurm-exporter/exporter_logging.conf"): + logging.config.fileConfig("/opt/azurehpc/azslurm-exporter/exporter_logging.conf") loop = asyncio.get_running_loop() stop_event = asyncio.Event() diff --git a/azure-slurm-exporter/install.sh b/azure-slurm-exporter/install.sh index ccdcbf50..5952b8d3 100755 --- a/azure-slurm-exporter/install.sh +++ b/azure-slurm-exporter/install.sh @@ -58,6 +58,7 @@ EOF } setup_install_dir() { + mkdir -p $INSTALL_DIR/logs cp exporter_logging.conf $INSTALL_DIR/ } From 77e12afb4ca0ec008ea4f8b09e76081293c53a24 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Wed, 4 Mar 2026 10:34:12 -0500 Subject: [PATCH 12/22] add add_dashboards script for monitoring project --- azure-slurm-exporter/add_dashboards.sh | 27 +++++++++++++++++++ .../{failed_jobs.json => failed-jobs.json} | 0 2 files changed, 27 insertions(+) create mode 100755 azure-slurm-exporter/add_dashboards.sh rename azure-slurm-exporter/dashboards/{failed_jobs.json => failed-jobs.json} (100%) diff --git a/azure-slurm-exporter/add_dashboards.sh b/azure-slurm-exporter/add_dashboards.sh new file mode 100755 index 00000000..39b659d0 --- /dev/null +++ b/azure-slurm-exporter/add_dashboards.sh @@ -0,0 +1,27 @@ +#!/bin/bash +EXPORTER_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +echo "Exporter directory: $EXPORTER_DIR" +RESOURCE_GROUP_NAME=$1 +GRAFANA_NAME=$2 + +if [ -z "$GRAFANA_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi +if [ -z "$RESOURCE_GROUP_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +FOLDER_NAME="Azure CycleCloud" +DASHBOARD_FOLDER=$EXPORTER_DIR/dashboards +# Create Grafana dashboards folders +az grafana folder show -n $GRAFANA_NAME -g $RESOURCE_GROUP_NAME --folder "$FOLDER_NAME" > /dev/null 2>&1 +if [ $? -ne 0 ]; then + echo "$FOLDER_NAME folder does not exist. Creating it." + az grafana folder create --name $GRAFANA_NAME --resource-group $RESOURCE_GROUP_NAME --title "$FOLDER_NAME" +fi + +# Slurm Dashboard +az grafana dashboard import --name $GRAFANA_NAME --resource-group $RESOURCE_GROUP_NAME --folder "$FOLDER_NAME" --overwrite true --definition $DASHBOARD_FOLDER/azslurm.json +az grafana dashboard import --name $GRAFANA_NAME --resource-group $RESOURCE_GROUP_NAME --folder "$FOLDER_NAME" --overwrite true --definition $DASHBOARD_FOLDER/failed-jobs.json \ No newline at end of file diff --git a/azure-slurm-exporter/dashboards/failed_jobs.json b/azure-slurm-exporter/dashboards/failed-jobs.json similarity index 100% rename from azure-slurm-exporter/dashboards/failed_jobs.json rename to azure-slurm-exporter/dashboards/failed-jobs.json From d4f301cefc45dc5764faa0d5b8ec475bc60dbd83 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Wed, 4 Mar 2026 11:03:36 -0500 Subject: [PATCH 13/22] fix logging --- azure-slurm-exporter/exporter/exporter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-slurm-exporter/exporter/exporter.py b/azure-slurm-exporter/exporter/exporter.py index 715ebc4d..aa46eb96 100644 --- a/azure-slurm-exporter/exporter/exporter.py +++ b/azure-slurm-exporter/exporter/exporter.py @@ -13,7 +13,7 @@ from prometheus_client.aiohttp import make_aiohttp_handler from typing import Iterator, List, Union -log = logging.getLogger(__name__) +log = logging.getLogger("root") CommandResult = namedtuple("CommandResult", ["returncode", "stdout", "stderr"]) class NoCollectorsFoundException(Exception): From 4fd0f8c1ef57cc52ac1671737815f8c243762dd4 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Wed, 4 Mar 2026 11:06:05 -0500 Subject: [PATCH 14/22] fix azslurm dashboard name --- azure-slurm-exporter/dashboards/{aszlsurm.json => azslurm.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename azure-slurm-exporter/dashboards/{aszlsurm.json => azslurm.json} (100%) diff --git a/azure-slurm-exporter/dashboards/aszlsurm.json b/azure-slurm-exporter/dashboards/azslurm.json similarity index 100% rename from azure-slurm-exporter/dashboards/aszlsurm.json rename to azure-slurm-exporter/dashboards/azslurm.json From dc8ac667eef86ad1f196b6cef6893b470c9f1e63 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Wed, 4 Mar 2026 11:13:12 -0500 Subject: [PATCH 15/22] configure azslurm-exporter in prometheus.yml --- azure-slurm-install/install.py | 56 +++++++++---------- ...lurm_exporter.yml => azslurm-exporter.yml} | 4 +- 2 files changed, 30 insertions(+), 30 deletions(-) rename azure-slurm-install/templates/{slurm_exporter.yml => azslurm-exporter.yml} (72%) diff --git a/azure-slurm-install/install.py b/azure-slurm-install/install.py index 15c303cf..0fb606bd 100644 --- a/azure-slurm-install/install.py +++ b/azure-slurm-install/install.py @@ -194,13 +194,13 @@ def setup_users(s: InstallSettings) -> None: uid=s.munge_uid, gid=s.munge_gid, ) - + if s.platform_family == "suse": logging.warning("slurmrestd user configuration is not supported on SUSE platforms, skipping this step.") return - + ilib.group(s.slurmrestd_grp, gid=s.slurmrestd_gid) - + ilib.user( s.slurmrestd_user, comment="User to run slurmrestd", @@ -394,7 +394,7 @@ def _accounting_primary(s: InstallSettings) -> None: "slurmver": s.slurmver, "storageloc": s.acct_storageloc or f"{s.slurm_db_cluster_name}_acct_db", "auth_alt_type": "AuthAltTypes=auth/jwt" if s.monitoring_enabled else "", - "auth_alt_parameters": f"AuthAltParameters=jwt_key={s.jwt_key_path}" + "auth_alt_parameters": f"AuthAltParameters=jwt_key={s.jwt_key_path}" if s.monitoring_enabled else "" }, ) @@ -461,7 +461,7 @@ def _complete_install_primary(s: InstallSettings) -> None: if not os.path.exists(state_save_location): ilib.directory(state_save_location, owner=s.slurm_user, group=s.slurm_grp) - + if not os.path.exists(f"{s.config_dir}/prolog.d"): ilib.directory(f"{s.config_dir}/prolog.d", owner=s.slurm_user, group=s.slurm_grp) @@ -497,7 +497,7 @@ def _complete_install_primary(s: InstallSettings) -> None: "health_interval": health_interval, "health_program": health_program, "auth_alt_type": "AuthAltTypes=auth/jwt" if s.monitoring_enabled else "", - "auth_alt_parameters": f"AuthAltParameters=jwt_key={s.jwt_key_path}" + "auth_alt_parameters": f"AuthAltParameters=jwt_key={s.jwt_key_path}" if s.monitoring_enabled else "" }, ) @@ -514,7 +514,7 @@ def _complete_install_primary(s: InstallSettings) -> None: group="root", mode="0755", ) - + ilib.copy_file( "imex_epilog.sh", f"{s.config_dir}/epilog.d/90-imex_epilog.sh", @@ -802,11 +802,11 @@ def setup_slurmrestd(s: InstallSettings) -> None: if s.mode != "scheduler": logging.info("Running on non-scheduler node skipping this step.") return - + if s.platform_family == "suse": logging.warning("slurmrestd configuration is not supported on SUSE platforms, skipping this step.") return - + # Add slurmrestd to docker group try: ilib.group("docker", gid=None) @@ -822,7 +822,7 @@ def setup_slurmrestd(s: InstallSettings) -> None: group=s.slurmrestd_grp, mode="0644", ) - + ilib.directory( "/var/spool/slurmrestd", owner=s.slurmrestd_user, group=s.slurmrestd_grp, mode=755 ) @@ -847,7 +847,7 @@ def setup_slurmrestd(s: InstallSettings) -> None: if s.monitoring_enabled: _configure_jwt_authentication(s) - _add_slurm_exporter_scraper(s, "/opt/prometheus/prometheus.yml", "templates/slurm_exporter.yml") + _add_azslurm_exporter_scraper(s, "/opt/prometheus/prometheus.yml", "templates/azslurm-exporter.yml") ilib.enable_service("slurmrestd") @@ -874,22 +874,22 @@ def _configure_jwt_authentication(s: InstallSettings) -> None: ilib.chmod(jwt_dir, mode=755) ilib.chmod(os.path.dirname(jwt_dir), mode=755) -def _add_slurm_exporter_scraper(s: InstallSettings, prom_config: str, exporter_yaml: str) -> None: +def _add_azslurm_exporter_scraper(s: InstallSettings, prom_config: str, exporter_yaml: str) -> None: """ - Add slurm_exporter scrape config to Prometheus. + Add azslurm-exporter scrape config to Prometheus. """ if not s.is_primary_scheduler: - logging.info("Not primary scheduler, skipping slurm_exporter configuration.") + logging.info("Not primary scheduler, skipping azslurm-exporter configuration.") return if not os.path.isfile(prom_config): - logging.warning("Prometheus configuration file not found, skipping slurm_exporter configuration.") + logging.warning("Prometheus configuration file not found, skipping azslurm-exporter configuration.") return - + with open(prom_config, "r") as f: prom_content = f.read() - if "slurm_exporter" in prom_content: - print("Slurm Exporter is already configured in Prometheus") + if "azslurm_exporter" in prom_content: + print("AzSlurm Exporter is already configured in Prometheus") return # Merge YAML files with open(prom_config, "r") as f: @@ -938,7 +938,7 @@ def _get_enroot_scratch_base_dir() -> str: # Determine scratch directory based on available mounts scratch_base_dir = _get_enroot_scratch_base_dir() enroot_scratch_dir = f"{scratch_base_dir}/enroot" - + # Create the enroot directory ilib.directory(enroot_scratch_dir, owner="root", group="root", mode=755) @@ -947,7 +947,7 @@ def _get_enroot_scratch_base_dir() -> str: for subdir in subdirs: full_path = f"{enroot_scratch_dir}/{subdir}" ilib.directory(full_path, owner="root", group="root", mode=777) - + ilib.template( f"/etc/enroot/enroot.conf", owner=s.slurm_user, @@ -962,13 +962,13 @@ def _get_enroot_scratch_base_dir() -> str: if s.mode == "execute": # Ensure hooks directory exists ilib.directory("/etc/enroot/hooks.d", owner="root", group="root", mode=755) - + # Copy hook files hook_files = ["50-slurm-pmi.sh", "50-slurm-pytorch.sh"] for hook_file in hook_files: source_path = f"/usr/share/enroot/hooks.d/{hook_file}" dest_path = f"/etc/enroot/hooks.d/{hook_file}" - + if os.path.exists(source_path): ilib.copy_file( source_path, @@ -979,7 +979,7 @@ def _get_enroot_scratch_base_dir() -> str: ) else: logging.warning(f"Hook file {source_path} not found, skipping") - + # Create the pyxis.conf file with the required plugin configuration pyxis_config = f'required /opt/pyxis/spank_pyxis.so runtime_path={enroot_scratch_dir}/enroot-runtime' ilib.file( @@ -997,7 +997,7 @@ def _update_prom_config(s: InstallSettings, prom_config: str, host_name: str) -> if not s.monitoring_enabled or not os.path.isfile(prom_config): logging.info("Monitoring is not enabled or prometheus config is not found, skipping Prometheus configuration update.") return - + with open(prom_config, "r") as f: prom_content = f.read() @@ -1016,7 +1016,7 @@ def _update_prom_config(s: InstallSettings, prom_config: str, host_name: str) -> group="root", mode="0644" ) - + def set_hostname(s: InstallSettings) -> None: if not s.use_nodename_as_hostname: return @@ -1031,10 +1031,10 @@ def set_hostname(s: InstallSettings) -> None: ilib.set_hostname( new_hostname, s.platform_family, s.ensure_waagent_monitor_hostname ) - + #Update prom config with new hostname _update_prom_config(s, "/opt/prometheus/prometheus.yml", new_hostname) - + if _is_at_least_ubuntu22() and s.ubuntu22_waagent_fix: logging.warning("Restarting systemd-networkd to fix waagent/hostname issue on Ubuntu 22.04." + " To disable this, set slurm.ubuntu22_waagent_fix=false under this" + @@ -1058,7 +1058,7 @@ def _is_at_least_ubuntu22() -> bool: if lsb_rel.get("ID") == "ubuntu" and lsb_rel.get("VERSION_ID", "") >= "22.04": return True - + return False diff --git a/azure-slurm-install/templates/slurm_exporter.yml b/azure-slurm-install/templates/azslurm-exporter.yml similarity index 72% rename from azure-slurm-install/templates/slurm_exporter.yml rename to azure-slurm-install/templates/azslurm-exporter.yml index ce4e11a0..8ed51055 100644 --- a/azure-slurm-install/templates/slurm_exporter.yml +++ b/azure-slurm-install/templates/azslurm-exporter.yml @@ -1,7 +1,7 @@ scrape_configs: - - job_name: slurm_exporter + - job_name: azslurm_exporter static_configs: - - targets: ["instance_name:9200"] + - targets: ["instance_name:9101"] relabel_configs: - source_labels: [__address__] target_label: instance From 27477bb77094fd39614b13afe18fb8fe90508d61 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Wed, 4 Mar 2026 12:00:47 -0500 Subject: [PATCH 16/22] update vars in azslurm.json and failed-jobs.json --- azure-slurm-exporter/dashboards/azslurm.json | 95 ++----------------- .../dashboards/failed-jobs.json | 88 ++--------------- 2 files changed, 17 insertions(+), 166 deletions(-) diff --git a/azure-slurm-exporter/dashboards/azslurm.json b/azure-slurm-exporter/dashboards/azslurm.json index 30b024bc..acc4dee4 100644 --- a/azure-slurm-exporter/dashboards/azslurm.json +++ b/azure-slurm-exporter/dashboards/azslurm.json @@ -2655,14 +2655,14 @@ "type": "prometheus", "uid": "${promDatasource}" }, - "definition": "label_values(azslurm_cluster_info,subscription)", + "definition": "label_values(jetpack_cluster_info,subscription)", "includeAll": false, "label": "Subscription", "name": "Subscription", "options": [], "query": { "qryType": 1, - "query": "label_values(azslurm_cluster_info,subscription)", + "query": "label_values(jetpack_cluster_info,subscription)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, @@ -2671,8 +2671,8 @@ }, { "current": { - "text": "Managed_Prometheus_ccw-mon-jwna6fuhty7ho", - "value": "ccw-mon-jwna6fuhty7ho" + "text": "Managed_Prometheus_ccw-mon-xfnnjso7smaw6", + "value": "ccw-mon-xfnnjso7smaw6" }, "includeAll": false, "label": "Prometheus Data Source", @@ -2686,22 +2686,20 @@ { "current": { "text": "All", - "value": [ - "$__all" - ] + "value": "$__all" }, "datasource": { "type": "prometheus", "uid": "${promDatasource}" }, - "definition": "label_values(squeue_partition_jobs_running, partition)", + "definition": "label_values(sinfo_partition_nodes_state, partition)", "includeAll": true, "multi": true, "name": "partition", "options": [], "query": { "qryType": 5, - "query": "label_values(squeue_partition_jobs_running, partition)", + "query": "label_values(sinfo_partition_nodes_state, partition)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, @@ -2710,8 +2708,8 @@ }, { "current": { - "text": "azcyclecloudwesteu-rg/u24-rc", - "value": "azcyclecloudwesteu-rg/u24-rc" + "text": "azcyclecloudwesteu-rg/azslurm-exporter-34-copy", + "value": "azcyclecloudwesteu-rg/azslurm-exporter-34-copy" }, "datasource": { "type": "prometheus", @@ -2728,81 +2726,6 @@ "refresh": 1, "regex": "", "type": "query" - }, - { - "allowCustomValue": false, - "current": { - "text": "2025-09-05", - "value": "2025-09-05" - }, - "datasource": { - "type": "prometheus", - "uid": "${promDatasource}" - }, - "definition": "label_values(sacct_jobs_total_six_months_completed, start_date)", - "hide": 2, - "includeAll": false, - "name": "six_month_date", - "options": [], - "query": { - "qryType": 5, - "query": "label_values(sacct_jobs_total_six_months_completed, start_date)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "sort": 4, - "type": "query" - }, - { - "allowCustomValue": false, - "current": { - "text": "2026-02-25", - "value": "2026-02-25" - }, - "datasource": { - "type": "prometheus", - "uid": "${promDatasource}" - }, - "definition": "label_values(sacct_jobs_total_one_week_completed, start_date)", - "hide": 2, - "includeAll": false, - "name": "one_week_date", - "options": [], - "query": { - "qryType": 5, - "query": "label_values(sacct_jobs_total_one_week_completed, start_date)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "sort": 4, - "type": "query" - }, - { - "allowCustomValue": false, - "current": { - "text": "2026-02-02", - "value": "2026-02-02" - }, - "datasource": { - "type": "prometheus", - "uid": "${promDatasource}" - }, - "definition": "label_values(sacct_jobs_total_one_month_completed, start_date)", - "hide": 2, - "includeAll": false, - "name": "one_month_date", - "options": [], - "query": { - "qryType": 5, - "query": "label_values(sacct_jobs_total_one_month_completed, start_date)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "sort": 4, - "type": "query" } ] }, diff --git a/azure-slurm-exporter/dashboards/failed-jobs.json b/azure-slurm-exporter/dashboards/failed-jobs.json index b65f2a7c..a917aa92 100644 --- a/azure-slurm-exporter/dashboards/failed-jobs.json +++ b/azure-slurm-exporter/dashboards/failed-jobs.json @@ -260,14 +260,14 @@ "type": "prometheus", "uid": "${promDatasource}" }, - "definition": "label_values(azslurm_cluster_info,subscription)", + "definition": "label_values(jetpack_cluster_info,subscription)", "includeAll": false, "label": "Subscription", "name": "Subscription", "options": [], "query": { "qryType": 1, - "query": "label_values(azslurm_cluster_info,subscription)", + "query": "label_values(jetpack_cluster_info,subscription)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, @@ -276,8 +276,8 @@ }, { "current": { - "text": "Managed_Prometheus_ccw-mon-jwna6fuhty7ho", - "value": "ccw-mon-jwna6fuhty7ho" + "text": "Managed_Prometheus_ccw-mon-xfnnjso7smaw6", + "value": "ccw-mon-xfnnjso7smaw6" }, "includeAll": false, "label": "Prometheus Data Source", @@ -299,14 +299,14 @@ "type": "prometheus", "uid": "${promDatasource}" }, - "definition": "label_values(squeue_partition_jobs_running, partition)", + "definition": "label_values(sinfo_partition_nodes_state, partition)", "includeAll": true, "multi": true, "name": "partition", "options": [], "query": { "qryType": 5, - "query": "label_values(squeue_partition_jobs_running, partition)", + "query": "label_values(sinfo_partition_nodes_state, partition)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, @@ -315,8 +315,8 @@ }, { "current": { - "text": "azcyclecloudwesteu-rg/u24-rc", - "value": "azcyclecloudwesteu-rg/u24-rc" + "text": "azcyclecloudwesteu-rg/azslurm-exporter-34-copy", + "value": "azcyclecloudwesteu-rg/azslurm-exporter-34-copy" }, "datasource": { "type": "prometheus", @@ -333,78 +333,6 @@ "refresh": 1, "regex": "", "type": "query" - }, - { - "allowCustomValue": false, - "current": { - "text": "2025-09-04", - "value": "2025-09-04" - }, - "datasource": { - "type": "prometheus", - "uid": "${promDatasource}" - }, - "definition": "label_values(sacct_jobs_total_six_months_completed, start_date)", - "hide": 2, - "includeAll": false, - "name": "six_month_date", - "options": [], - "query": { - "qryType": 5, - "query": "label_values(sacct_jobs_total_six_months_completed, start_date)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "type": "query" - }, - { - "allowCustomValue": false, - "current": { - "text": "2026-02-24", - "value": "2026-02-24" - }, - "datasource": { - "type": "prometheus", - "uid": "${promDatasource}" - }, - "definition": "label_values(sacct_jobs_total_one_week_completed, start_date)", - "hide": 2, - "includeAll": false, - "name": "one_week_date", - "options": [], - "query": { - "qryType": 5, - "query": "label_values(sacct_jobs_total_one_week_completed, start_date)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "type": "query" - }, - { - "allowCustomValue": false, - "current": { - "text": "2026-02-01", - "value": "2026-02-01" - }, - "datasource": { - "type": "prometheus", - "uid": "${promDatasource}" - }, - "definition": "label_values(sacct_jobs_total_one_month_completed, start_date)", - "hide": 2, - "includeAll": false, - "name": "one_month_date", - "options": [], - "query": { - "qryType": 5, - "query": "label_values(sacct_jobs_total_one_month_completed, start_date)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "type": "query" } ] }, From a73d4b07f8a233184d1ac9ec5452cd13b4cbf865 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Wed, 4 Mar 2026 14:08:05 -0500 Subject: [PATCH 17/22] update prometheus.yml in install.sh --- .../azslurm-exporter.yml | 0 azure-slurm-exporter/install.sh | 28 +++++++++++ azure-slurm-install/install.py | 46 ------------------- 3 files changed, 28 insertions(+), 46 deletions(-) rename {azure-slurm-install/templates => azure-slurm-exporter}/azslurm-exporter.yml (100%) diff --git a/azure-slurm-install/templates/azslurm-exporter.yml b/azure-slurm-exporter/azslurm-exporter.yml similarity index 100% rename from azure-slurm-install/templates/azslurm-exporter.yml rename to azure-slurm-exporter/azslurm-exporter.yml diff --git a/azure-slurm-exporter/install.sh b/azure-slurm-exporter/install.sh index 5952b8d3..6ce8c0f0 100755 --- a/azure-slurm-exporter/install.sh +++ b/azure-slurm-exporter/install.sh @@ -56,6 +56,32 @@ EOF ln -sf $VENV/bin/azslurm-exporter ~/bin/ } +add_scraper() { + # If az_exporter is already configured, do not add it again + if grep -q "azslurm_exporter" $PROM_CONFIG; then + echo "AzSlurm Exporter is already configured in Prometheus" + return 0 + fi + INSTANCE_NAME=$(hostname) + + cat > azslurm-exporter.yml <<-EOF + scrape_configs: + - job_name: azslurm_exporter + static_configs: + - targets: ["instance_name:9101"] + relabel_configs: + - source_labels: [__address__] + target_label: instance + regex: '([^:]+)(:[0-9]+)?' + replacement: '\${1}' +EOF + + yq eval-all '. as $item ireduce ({}; . *+ $item)' $PROM_CONFIG azslurm-exporter.yml > tmp.yml + mv -vf tmp.yml $PROM_CONFIG + + # update the configuration file + sed -i "s/instance_name/$INSTANCE_NAME/g" $PROM_CONFIG +} setup_install_dir() { mkdir -p $INSTALL_DIR/logs @@ -96,6 +122,7 @@ parse_args_set_variables() { # Set this globally before running main. export PYTHON_PATH=$(find_python3) export PATH=$PATH:/root/bin + export PROM_CONFIG=/opt/prometheus/prometheus.yml while (( "$#" )); do case "$1" in @@ -119,6 +146,7 @@ main() { # create the venv and make sure azslurm-exporter is in the path setup_venv setup_install_dir + add_scraper # setup the azslurm-exporter but do not start it. setup_azslurm_exporter } diff --git a/azure-slurm-install/install.py b/azure-slurm-install/install.py index 0fb606bd..ad3f54c4 100644 --- a/azure-slurm-install/install.py +++ b/azure-slurm-install/install.py @@ -847,7 +847,6 @@ def setup_slurmrestd(s: InstallSettings) -> None: if s.monitoring_enabled: _configure_jwt_authentication(s) - _add_azslurm_exporter_scraper(s, "/opt/prometheus/prometheus.yml", "templates/azslurm-exporter.yml") ilib.enable_service("slurmrestd") @@ -874,51 +873,6 @@ def _configure_jwt_authentication(s: InstallSettings) -> None: ilib.chmod(jwt_dir, mode=755) ilib.chmod(os.path.dirname(jwt_dir), mode=755) -def _add_azslurm_exporter_scraper(s: InstallSettings, prom_config: str, exporter_yaml: str) -> None: - """ - Add azslurm-exporter scrape config to Prometheus. - """ - if not s.is_primary_scheduler: - logging.info("Not primary scheduler, skipping azslurm-exporter configuration.") - return - - if not os.path.isfile(prom_config): - logging.warning("Prometheus configuration file not found, skipping azslurm-exporter configuration.") - return - - with open(prom_config, "r") as f: - prom_content = f.read() - if "azslurm_exporter" in prom_content: - print("AzSlurm Exporter is already configured in Prometheus") - return - # Merge YAML files - with open(prom_config, "r") as f: - prom_yaml = yaml.safe_load(f) or {} - with open(exporter_yaml, "r") as f: - exporter_yaml_content = yaml.safe_load(f) or {} - - # Simple merge: add/replace scrape_configs - def merge_scrape_configs(base, overlay): - base_scrapes = base.get("scrape_configs", []) - overlay_scrapes = overlay.get("scrape_configs", []) - base["scrape_configs"] = base_scrapes + overlay_scrapes - return base - - merged_yaml = merge_scrape_configs(prom_yaml, exporter_yaml_content) - - # Replace instance_name placeholder - merged_str = yaml.safe_dump(merged_yaml, default_flow_style=False) - merged_str = merged_str.replace("instance_name", s.hostname) - - # Write back to prom_config - ilib.file( - prom_config, - content=merged_str, - owner="root", - group="root", - mode="0644" - ) - def _configure_enroot_pyxis(s: InstallSettings) -> None: if s.platform_family == "suse" or (s.platform_family == "rhel" and s.major_version != 8): logging.warning("Enroot is only supported on Ubuntu and RHEL/AlmaLinux 8. Skipping enroot configuration.") From 64ed5982026da439d2bc9c6d46bb33e45c32a80c Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Wed, 4 Mar 2026 14:42:39 -0500 Subject: [PATCH 18/22] revert all non exporter files --- azure-slurm-install/install.py | 86 ++++++++++++++----- azure-slurm-install/start-services.sh | 65 ++++++++++---- .../templates/slurm_exporter.yml | 9 ++ project.ini | 2 +- .../cluster-init/scripts/00-install.sh | 20 ++--- util/build.sh | 5 -- 6 files changed, 128 insertions(+), 59 deletions(-) create mode 100644 azure-slurm-install/templates/slurm_exporter.yml diff --git a/azure-slurm-install/install.py b/azure-slurm-install/install.py index ad3f54c4..15c303cf 100644 --- a/azure-slurm-install/install.py +++ b/azure-slurm-install/install.py @@ -194,13 +194,13 @@ def setup_users(s: InstallSettings) -> None: uid=s.munge_uid, gid=s.munge_gid, ) - + if s.platform_family == "suse": logging.warning("slurmrestd user configuration is not supported on SUSE platforms, skipping this step.") return - + ilib.group(s.slurmrestd_grp, gid=s.slurmrestd_gid) - + ilib.user( s.slurmrestd_user, comment="User to run slurmrestd", @@ -394,7 +394,7 @@ def _accounting_primary(s: InstallSettings) -> None: "slurmver": s.slurmver, "storageloc": s.acct_storageloc or f"{s.slurm_db_cluster_name}_acct_db", "auth_alt_type": "AuthAltTypes=auth/jwt" if s.monitoring_enabled else "", - "auth_alt_parameters": f"AuthAltParameters=jwt_key={s.jwt_key_path}" + "auth_alt_parameters": f"AuthAltParameters=jwt_key={s.jwt_key_path}" if s.monitoring_enabled else "" }, ) @@ -461,7 +461,7 @@ def _complete_install_primary(s: InstallSettings) -> None: if not os.path.exists(state_save_location): ilib.directory(state_save_location, owner=s.slurm_user, group=s.slurm_grp) - + if not os.path.exists(f"{s.config_dir}/prolog.d"): ilib.directory(f"{s.config_dir}/prolog.d", owner=s.slurm_user, group=s.slurm_grp) @@ -497,7 +497,7 @@ def _complete_install_primary(s: InstallSettings) -> None: "health_interval": health_interval, "health_program": health_program, "auth_alt_type": "AuthAltTypes=auth/jwt" if s.monitoring_enabled else "", - "auth_alt_parameters": f"AuthAltParameters=jwt_key={s.jwt_key_path}" + "auth_alt_parameters": f"AuthAltParameters=jwt_key={s.jwt_key_path}" if s.monitoring_enabled else "" }, ) @@ -514,7 +514,7 @@ def _complete_install_primary(s: InstallSettings) -> None: group="root", mode="0755", ) - + ilib.copy_file( "imex_epilog.sh", f"{s.config_dir}/epilog.d/90-imex_epilog.sh", @@ -802,11 +802,11 @@ def setup_slurmrestd(s: InstallSettings) -> None: if s.mode != "scheduler": logging.info("Running on non-scheduler node skipping this step.") return - + if s.platform_family == "suse": logging.warning("slurmrestd configuration is not supported on SUSE platforms, skipping this step.") return - + # Add slurmrestd to docker group try: ilib.group("docker", gid=None) @@ -822,7 +822,7 @@ def setup_slurmrestd(s: InstallSettings) -> None: group=s.slurmrestd_grp, mode="0644", ) - + ilib.directory( "/var/spool/slurmrestd", owner=s.slurmrestd_user, group=s.slurmrestd_grp, mode=755 ) @@ -847,6 +847,7 @@ def setup_slurmrestd(s: InstallSettings) -> None: if s.monitoring_enabled: _configure_jwt_authentication(s) + _add_slurm_exporter_scraper(s, "/opt/prometheus/prometheus.yml", "templates/slurm_exporter.yml") ilib.enable_service("slurmrestd") @@ -873,6 +874,51 @@ def _configure_jwt_authentication(s: InstallSettings) -> None: ilib.chmod(jwt_dir, mode=755) ilib.chmod(os.path.dirname(jwt_dir), mode=755) +def _add_slurm_exporter_scraper(s: InstallSettings, prom_config: str, exporter_yaml: str) -> None: + """ + Add slurm_exporter scrape config to Prometheus. + """ + if not s.is_primary_scheduler: + logging.info("Not primary scheduler, skipping slurm_exporter configuration.") + return + + if not os.path.isfile(prom_config): + logging.warning("Prometheus configuration file not found, skipping slurm_exporter configuration.") + return + + with open(prom_config, "r") as f: + prom_content = f.read() + if "slurm_exporter" in prom_content: + print("Slurm Exporter is already configured in Prometheus") + return + # Merge YAML files + with open(prom_config, "r") as f: + prom_yaml = yaml.safe_load(f) or {} + with open(exporter_yaml, "r") as f: + exporter_yaml_content = yaml.safe_load(f) or {} + + # Simple merge: add/replace scrape_configs + def merge_scrape_configs(base, overlay): + base_scrapes = base.get("scrape_configs", []) + overlay_scrapes = overlay.get("scrape_configs", []) + base["scrape_configs"] = base_scrapes + overlay_scrapes + return base + + merged_yaml = merge_scrape_configs(prom_yaml, exporter_yaml_content) + + # Replace instance_name placeholder + merged_str = yaml.safe_dump(merged_yaml, default_flow_style=False) + merged_str = merged_str.replace("instance_name", s.hostname) + + # Write back to prom_config + ilib.file( + prom_config, + content=merged_str, + owner="root", + group="root", + mode="0644" + ) + def _configure_enroot_pyxis(s: InstallSettings) -> None: if s.platform_family == "suse" or (s.platform_family == "rhel" and s.major_version != 8): logging.warning("Enroot is only supported on Ubuntu and RHEL/AlmaLinux 8. Skipping enroot configuration.") @@ -892,7 +938,7 @@ def _get_enroot_scratch_base_dir() -> str: # Determine scratch directory based on available mounts scratch_base_dir = _get_enroot_scratch_base_dir() enroot_scratch_dir = f"{scratch_base_dir}/enroot" - + # Create the enroot directory ilib.directory(enroot_scratch_dir, owner="root", group="root", mode=755) @@ -901,7 +947,7 @@ def _get_enroot_scratch_base_dir() -> str: for subdir in subdirs: full_path = f"{enroot_scratch_dir}/{subdir}" ilib.directory(full_path, owner="root", group="root", mode=777) - + ilib.template( f"/etc/enroot/enroot.conf", owner=s.slurm_user, @@ -916,13 +962,13 @@ def _get_enroot_scratch_base_dir() -> str: if s.mode == "execute": # Ensure hooks directory exists ilib.directory("/etc/enroot/hooks.d", owner="root", group="root", mode=755) - + # Copy hook files hook_files = ["50-slurm-pmi.sh", "50-slurm-pytorch.sh"] for hook_file in hook_files: source_path = f"/usr/share/enroot/hooks.d/{hook_file}" dest_path = f"/etc/enroot/hooks.d/{hook_file}" - + if os.path.exists(source_path): ilib.copy_file( source_path, @@ -933,7 +979,7 @@ def _get_enroot_scratch_base_dir() -> str: ) else: logging.warning(f"Hook file {source_path} not found, skipping") - + # Create the pyxis.conf file with the required plugin configuration pyxis_config = f'required /opt/pyxis/spank_pyxis.so runtime_path={enroot_scratch_dir}/enroot-runtime' ilib.file( @@ -951,7 +997,7 @@ def _update_prom_config(s: InstallSettings, prom_config: str, host_name: str) -> if not s.monitoring_enabled or not os.path.isfile(prom_config): logging.info("Monitoring is not enabled or prometheus config is not found, skipping Prometheus configuration update.") return - + with open(prom_config, "r") as f: prom_content = f.read() @@ -970,7 +1016,7 @@ def _update_prom_config(s: InstallSettings, prom_config: str, host_name: str) -> group="root", mode="0644" ) - + def set_hostname(s: InstallSettings) -> None: if not s.use_nodename_as_hostname: return @@ -985,10 +1031,10 @@ def set_hostname(s: InstallSettings) -> None: ilib.set_hostname( new_hostname, s.platform_family, s.ensure_waagent_monitor_hostname ) - + #Update prom config with new hostname _update_prom_config(s, "/opt/prometheus/prometheus.yml", new_hostname) - + if _is_at_least_ubuntu22() and s.ubuntu22_waagent_fix: logging.warning("Restarting systemd-networkd to fix waagent/hostname issue on Ubuntu 22.04." + " To disable this, set slurm.ubuntu22_waagent_fix=false under this" + @@ -1012,7 +1058,7 @@ def _is_at_least_ubuntu22() -> bool: if lsb_rel.get("ID") == "ubuntu" and lsb_rel.get("VERSION_ID", "") >= "22.04": return True - + return False diff --git a/azure-slurm-install/start-services.sh b/azure-slurm-install/start-services.sh index a5eba5b0..7d8626b7 100644 --- a/azure-slurm-install/start-services.sh +++ b/azure-slurm-install/start-services.sh @@ -125,9 +125,9 @@ run_slurmrestd() { /opt/cycle/jetpack/bin/jetpack log "slurmrestd failed to start" --level=warn --priority=medium exit 0 fi - # start azslurm-exporter if monitoring is enabled and slurmrestd is running + # start slurm_exporter if monitoring is enabled and slurmrestd is running if [[ "$monitoring_enabled" == "True" ]]; then - run_azslurm_exporter + run_slurm_exporter fi } @@ -143,13 +143,13 @@ reload_prom_config(){ kill -HUP $PROM_PID else echo "Prometheus process not found, unable to reload configuration" - fi + fi } -run_azslurm_exporter() { - # start azslurm-exporter systemd +run_slurm_exporter() { + # Run Slurm Exporter in a container if [[ "$role" != "scheduler" ]]; then - echo "AzSlurm Exporter can only be run on the scheduler node, skipping setup." + echo "Slurm Exporter can only be run on the scheduler node, skipping setup." return 0 fi @@ -160,25 +160,54 @@ run_azslurm_exporter() { echo "This is not the primary scheduler, skipping slurm_exporter setup." return 0 fi + + SLURM_EXPORTER_PORT=9200 + SLURM_EXPORTER_IMAGE_NAME="ghcr.io/slinkyproject/slurm-exporter:0.3.0" + # Try to get the token, retry up to 3 times + unset SLURM_JWT + for attempt in 1 2 3; do + export $(scontrol token username="slurmrestd" lifespan=infinite) + if [ -n "$SLURM_JWT" ]; then + break + fi + echo "Attempt $attempt: Failed to get SLURM_JWT token, retrying in 5 seconds..." + scontrol reconfigure + sleep 5 + done - AZSLURM_EXPORTER_PORT=9101 - systemctl start azslurm-exporter - systemctl status azslurm-exporter --no-pager > /dev/null - if [ $? != 0 ]; then - echo "AzSlurm Exporter is not running" - /opt/cycle/jetpack/bin/jetpack log "AzSlurm Exporter systemd failed to start" --level=warn --priority=medium + if [ -z "$SLURM_JWT" ]; then + echo "Failed to get SLURM_JWT token after 3 attempts." + echo "Check slurmctld status, slurm.conf JWT configuration, and logs for errors." + /opt/cycle/jetpack/bin/jetpack log "Failed to get SLURM_JWT token after 3 attempts, disabling slurm_exporter setup." --level=warn --priority=medium + return 0 + fi + # Check if the container is already running, and if so, stop it + if [ "$(docker ps -q -f ancestor=$SLURM_EXPORTER_IMAGE_NAME)" ]; then + echo "Slurm Exporter is already running, stopping it..." + docker stop $(docker ps -q -f ancestor=$SLURM_EXPORTER_IMAGE_NAME) + fi + + # Run the Slurm Exporter container, expose the port so prometheus can scrape it. Redirect the host.docker.internal to the host gateway == localhost + docker run -v /var:/var -e SLURM_JWT=${SLURM_JWT} -d --restart always -p ${SLURM_EXPORTER_PORT}:8080 --add-host=host.docker.internal:host-gateway $SLURM_EXPORTER_IMAGE_NAME -server http://host.docker.internal:6820 -cache-freq 10s + + # Check if the container is running + if [ "$(docker ps -q -f ancestor=$SLURM_EXPORTER_IMAGE_NAME)" ]; then + echo "Slurm Exporter is running" + else + echo "Slurm Exporter is not running" + /opt/cycle/jetpack/bin/jetpack log "Slurm Exporter container failed to start" --level=warn --priority=medium return 0 # do not fail the slurm startup if exporter fails fi reload_prom_config - + sleep 20 - if curl -s http://localhost:${AZSLURM_EXPORTER_PORT}/metrics | grep -q "jetpack_cluster_info"; then + if curl -s http://localhost:${SLURM_EXPORTER_PORT}/metrics | grep -q "slurm_nodes_total"; then echo "Slurm Exporter metrics are available" else - echo "AzSlurm Exporter metrics are not available" + echo "Slurm Exporter metrics are not available" /opt/cycle/jetpack/bin/jetpack log "Slurm Exporter metrics are not available" --level=warn --priority=medium - fi + fi } ensure_enroot_dir() { @@ -207,7 +236,7 @@ ensure_enroot_dir() { chmod 1777 "$BASE_DIR" } -{ +{ if [ "$1" == "" ]; then echo "Usage: $0 [scheduler|execute|login]" exit 1 @@ -255,7 +284,7 @@ ensure_enroot_dir() { # lastly - the scheduler use_accounting=$(jetpack config slurm.accounting.enabled False) - if [ "$use_accounting" == "True" ]; then + if [ "$use_accounting" == "True" ]; then run_slurmdbd else echo "Warning: slurm.accounting.enabled=${use_accounting}: skipping slurmdbd" >&2 diff --git a/azure-slurm-install/templates/slurm_exporter.yml b/azure-slurm-install/templates/slurm_exporter.yml new file mode 100644 index 00000000..ce4e11a0 --- /dev/null +++ b/azure-slurm-install/templates/slurm_exporter.yml @@ -0,0 +1,9 @@ +scrape_configs: + - job_name: slurm_exporter + static_configs: + - targets: ["instance_name:9200"] + relabel_configs: + - source_labels: [__address__] + target_label: instance + regex: '([^:]+)(:[0-9]+)?' + replacement: '${1}' \ No newline at end of file diff --git a/project.ini b/project.ini index 2a8bc3f2..656ae380 100644 --- a/project.ini +++ b/project.ini @@ -5,7 +5,7 @@ version = 4.0.6 type = scheduler [blobs] -Files = azure-slurm-pkg-4.0.6.tar.gz, azure-slurm-install-pkg-4.0.6.tar.gz, azure-slurm-exporter-pkg-4.0.6.tar.gz +Files = azure-slurm-pkg-4.0.6.tar.gz, azure-slurm-install-pkg-4.0.6.tar.gz [spec scheduler] run_list = role[slurm_scheduler_role] diff --git a/specs/scheduler/cluster-init/scripts/00-install.sh b/specs/scheduler/cluster-init/scripts/00-install.sh index 7036c0ff..4a20e025 100644 --- a/specs/scheduler/cluster-init/scripts/00-install.sh +++ b/specs/scheduler/cluster-init/scripts/00-install.sh @@ -4,9 +4,7 @@ set -e do_install=$(jetpack config slurm.do_install True) install_pkg=$(jetpack config slurm.install_pkg azure-slurm-install-pkg-4.0.6.tar.gz) autoscale_pkg=$(jetpack config slurm.autoscale_pkg azure-slurm-pkg-4.0.6.tar.gz) -exporter_pkg=$(jetpack config slurm.autoscale_pkg azure-slurm-exporter-pkg-4.0.6.tar.gz) slurm_project_name=$(jetpack config slurm.project_name slurm) -monitoring_enabled=$(jetpack config cyclecloud.monitoring.enabled False) find_python3() { export PATH=$(echo $PATH | sed -e 's/\/opt\/cycle\/jetpack\/system\/embedded\/bin://g' | sed -e 's/:\/opt\/cycle\/jetpack\/system\/embedded\/bin//g') @@ -54,29 +52,29 @@ install_python3() { echo "Detected AlmaLinux. Installing Python 3.12..." >&2 yum install -y python3.12 python3.12-pyyaml PYTHON_BIN="/usr/bin/python3.12" - + elif [ "$OS" == "ubuntu" ] && [ "$VERSION_ID" == "22.04" ]; then echo "Detected Ubuntu 22.04. Installing Python 3.11..." >&2 apt update # We need python dev headers and systemd dev headers for same reaosn mentioned above. apt install -y python3.11 python3.11-venv python3-yaml PYTHON_BIN="/usr/bin/python3.11" - + elif [ "$OS" == "ubuntu" ] && [[ $VERSION =~ ^24\.* ]]; then echo "Detected Ubuntu 24. Installing Python 3.12..." >&2 apt update apt install -y python3.12 python3.12-venv python3-yaml PYTHON_BIN="/usr/bin/python3.12" - + elif [ "$OS" == "rhel" ]; then echo "Detected RHEL, using system python3..." >&2 PYTHON_BIN="/usr/bin/python3" - + elif [ "$OS" == "sle_hpc" ]; then echo "Detected SUSE, installing Python 3.11..." >&2 zypper install -y python311 python311-virtualenv python311-PyYAML PYTHON_BIN="/usr/bin/python3.11" - + else echo "Unsupported operating system: $OS $VERSION_ID" >&2 exit 1 @@ -103,12 +101,4 @@ tar xzf $autoscale_pkg cd azure-slurm AZSLURM_PYTHON_PATH=$PYTHON_BIN ./install.sh -if [[ "$monitoring_enabled" == "True" ]]; then - rm -rf azure-slurm-exporter - jetpack download --project $slurm_project_name $exporter_pkg - tar xzf $exporter_pkg - cd azure-slurm-exporter - AZSLURM_PYTHON_PATH=$PYTHON_BIN ./install.sh -fi - echo "installation complete. Run start-services scheduler|execute|login to start the slurm services." diff --git a/util/build.sh b/util/build.sh index 7f1f35f0..2e909aa2 100755 --- a/util/build.sh +++ b/util/build.sh @@ -36,8 +36,3 @@ cd $SOURCE/azure-slurm rm -f dist/* ./package.sh $LOCAL_SCALELIB mv dist/* ../blobs/ - -cd $SOURCE/azure-slurm-exporter -rm -f dist/* -./package.sh -mv dist/* ../blobs/ From 759fc397f130624d21717c1e215a232d2b1cf05d Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Thu, 5 Mar 2026 14:27:15 -0500 Subject: [PATCH 19/22] build using pyproject.toml --- azure-slurm-exporter/azslurm-exporter.yml | 9 -- azure-slurm-exporter/exporter/exporter.py | 10 +- .../{conf => exporter}/exporter_logging.conf | 2 +- azure-slurm-exporter/exporter/main.py | 8 + azure-slurm-exporter/install.sh | 81 +++-------- azure-slurm-exporter/package.py | 108 -------------- azure-slurm-exporter/package.sh | 22 ++- azure-slurm-exporter/pyproject.toml | 31 ++++ azure-slurm-exporter/setup.py | 137 ------------------ 9 files changed, 77 insertions(+), 331 deletions(-) delete mode 100644 azure-slurm-exporter/azslurm-exporter.yml rename azure-slurm-exporter/{conf => exporter}/exporter_logging.conf (85%) create mode 100644 azure-slurm-exporter/exporter/main.py delete mode 100644 azure-slurm-exporter/package.py create mode 100644 azure-slurm-exporter/pyproject.toml delete mode 100644 azure-slurm-exporter/setup.py diff --git a/azure-slurm-exporter/azslurm-exporter.yml b/azure-slurm-exporter/azslurm-exporter.yml deleted file mode 100644 index 8ed51055..00000000 --- a/azure-slurm-exporter/azslurm-exporter.yml +++ /dev/null @@ -1,9 +0,0 @@ -scrape_configs: - - job_name: azslurm_exporter - static_configs: - - targets: ["instance_name:9101"] - relabel_configs: - - source_labels: [__address__] - target_label: instance - regex: '([^:]+)(:[0-9]+)?' - replacement: '${1}' \ No newline at end of file diff --git a/azure-slurm-exporter/exporter/exporter.py b/azure-slurm-exporter/exporter/exporter.py index aa46eb96..6763ab56 100644 --- a/azure-slurm-exporter/exporter/exporter.py +++ b/azure-slurm-exporter/exporter/exporter.py @@ -5,6 +5,7 @@ import sys import time import os +from importlib import resources from prometheus_client import CollectorRegistry, Metric, Counter, Gauge, Summary from abc import ABC, abstractmethod from functools import partial @@ -187,8 +188,8 @@ async def start_http_server(self, host:str, port:int) -> web.AppRunner: async def main(): - if os.path.exists("/opt/azurehpc/azslurm-exporter/exporter_logging.conf"): - logging.config.fileConfig("/opt/azurehpc/azslurm-exporter/exporter_logging.conf") + conf_file = resources.files("exporter").joinpath("exporter_logging.conf") + logging.config.fileConfig(str(conf_file)) loop = asyncio.get_running_loop() stop_event = asyncio.Event() @@ -214,7 +215,4 @@ async def main(): except asyncio.CancelledError: pass finally: - await runner.cleanup() - -if __name__=="__main__": - asyncio.run(main()) \ No newline at end of file + await runner.cleanup() \ No newline at end of file diff --git a/azure-slurm-exporter/conf/exporter_logging.conf b/azure-slurm-exporter/exporter/exporter_logging.conf similarity index 85% rename from azure-slurm-exporter/conf/exporter_logging.conf rename to azure-slurm-exporter/exporter/exporter_logging.conf index e8ce8691..944f8a3d 100644 --- a/azure-slurm-exporter/conf/exporter_logging.conf +++ b/azure-slurm-exporter/exporter/exporter_logging.conf @@ -15,7 +15,7 @@ handlers=consoleHandler, fileHandler class=logging.handlers.RotatingFileHandler level=DEBUG formatter=simpleFormatter -args=("/opt/azurehpc/azslurm-exporter/logs/azslurm-exporter.log", "a", 1024 * 1024 * 5, 5) +args=("/var/log/azslurm-exporter.log", "a", 1024 * 1024 * 5, 5) [handler_consoleHandler] class=StreamHandler diff --git a/azure-slurm-exporter/exporter/main.py b/azure-slurm-exporter/exporter/main.py new file mode 100644 index 00000000..58dcd5de --- /dev/null +++ b/azure-slurm-exporter/exporter/main.py @@ -0,0 +1,8 @@ +import asyncio +from exporter.exporter import main as async_main + +def main(): + asyncio.run(async_main()) + +if __name__=="__main__": + main() \ No newline at end of file diff --git a/azure-slurm-exporter/install.sh b/azure-slurm-exporter/install.sh index 6ce8c0f0..3ca3da41 100755 --- a/azure-slurm-exporter/install.sh +++ b/azure-slurm-exporter/install.sh @@ -32,29 +32,12 @@ setup_venv() { source $VENV/bin/activate set -e - # ensure wheel is installed - python3 -m pip install wheel - python3 -m pip install aiohttp - - # upgrade venv with packages from intallation - python3 -m pip install --upgrade --no-deps packages/* - - # Create exporter executable - cat > $VENV/bin/azslurm-exporter < /etc/systemd/system/azslurm-exporter.service <&2 - exit 1 - ;; - *) - echo "Unknown option $1" >&2 - exit 1 - ;; - esac - done -} - main() { - # create the venv and make sure azslurm-exporter is in the path + VERSION=0.1.0 + PACKAGE=azure_slurm_exporter-$VERSION.tar.gz + SCHEDULER=slurm + VENV=/opt/azurehpc/azslurm-exporter/venv + INSTALL_DIR=$(dirname $VENV) + PATH=$PATH:/root/bin + PROM_CONFIG=/opt/prometheus/prometheus.yml + + # create the venv and install azslurm-exporter setup_venv - setup_install_dir add_scraper - # setup the azslurm-exporter but do not start it. + # setup the azslurm-exporter systemd but do not start it. setup_azslurm_exporter } @@ -158,7 +110,8 @@ require_root() { fi } +# Set this globally before running main. +PYTHON_PATH=$(find_python3) require_root -parse_args_set_variables $@ main echo Installation complete. diff --git a/azure-slurm-exporter/package.py b/azure-slurm-exporter/package.py deleted file mode 100644 index fb5e3d32..00000000 --- a/azure-slurm-exporter/package.py +++ /dev/null @@ -1,108 +0,0 @@ -import argparse -import configparser -import glob -import pip -import os -import shutil -import sys -import tarfile -import tempfile -from argparse import Namespace -from subprocess import check_call -from typing import Dict, List, Optional - -def build_sdist() -> str: - check_call([sys.executable, "setup.py", "sdist"]) - # sometimes this is azure-slurm, sometimes it is azure_slurm, depenends on the build system version. - sdists = glob.glob("dist/azure*slurm*exporter*.tar.gz") - assert len(sdists) == 1, f"Found %d sdist packages, expected 1 - see {os.path.abspath('dist/azure-slurm-exporter*.tar.gz')}" % len(sdists) - path = sdists[0] - fname = os.path.basename(path) - dest = os.path.join("libs", fname) - if os.path.exists(dest): - os.remove(dest) - shutil.move(path, dest) - return fname - -def execute() -> None: - - expected_cwd = os.path.abspath(os.path.dirname(__file__)) - os.chdir(expected_cwd) - - print("Running from", expected_cwd) - - if not os.path.exists("libs"): - os.makedirs("libs") - - parser = configparser.ConfigParser() - ini_path = os.path.abspath("../project.ini") - - with open(ini_path) as fr: - parser.read_file(fr) - - version = parser.get("project", "version") - if not version: - raise RuntimeError("Missing [project] -> version in {}".format(ini_path)) - - ret = [build_sdist()] - - if not os.path.exists("dist"): - os.makedirs("dist") - - tf = tarfile.TarFile.gzopen( - "dist/azure-slurm-exporter-pkg-{}.tar.gz".format(version), "w" - ) - - build_dir = tempfile.mkdtemp("azure-slurm-exporter") - - - def _add(name: str, path: Optional[str] = None, mode: Optional[int] = None) -> None: - path = path or name - tarinfo = tarfile.TarInfo("azure-slurm-exporter/" + name) - tarinfo.size = os.path.getsize(path) - tarinfo.mtime = int(os.path.getmtime(path)) - if mode: - tarinfo.mode = mode - - with open(path, "rb") as fr: - tf.addfile(tarinfo, fr) - - packages = [] - for dep in ret: - dep_path = os.path.abspath(os.path.join("libs", dep)) - _add("packages/" + dep, dep_path) - packages.append(dep_path) - mypip = shutil.which("pip3") - print("my pip", mypip) - check_call([mypip, "download"] + packages, cwd=build_dir) - - print("Using build dir", build_dir) - by_package: Dict[str, List[str]] = {} - for fil in os.listdir(build_dir): - toks = fil.split("-", 1) - package = toks[0] - if package not in by_package: - by_package[package] = [] - by_package[package].append(fil) - - for package, fils in by_package.items(): - - if len(fils) > 1: - print("WARNING: Ignoring duplicate package found:", package, fils) - assert False - - for fil in os.listdir(build_dir): - # Skip platform-specific or unnecessary packages - skip_packages = ["aiohttp", "frozenlist","multidict","propcache","yarl"] - if any(pkg in fil.lower() for pkg in skip_packages): - print(f"WARNING: Ignoring unnecessary package {fil}, platform specific or not needed.") - continue - path = os.path.join(build_dir, fil) - _add("packages/" + fil, path) - - _add("exporter_logging.conf", "conf/exporter_logging.conf") - _add("install.sh", "install.sh", mode=os.stat("install.sh")[0]) - - -if __name__ == "__main__": - execute() diff --git a/azure-slurm-exporter/package.sh b/azure-slurm-exporter/package.sh index 50be9dea..bd3e61f3 100755 --- a/azure-slurm-exporter/package.sh +++ b/azure-slurm-exporter/package.sh @@ -1,10 +1,20 @@ #!/usr/bin/env bash set -e -if [ ! -e libs ]; then - mkdir libs +PROJECT_DIR=$(dirname "$0") +VENV_DIR="$PROJECT_DIR/.venv" +# Create a virtual environment if it does not exist +if [ ! -d "$VENV_DIR" ]; then + echo "Creating virtual environment at $VENV_DIR..." + python3 -m venv "$VENV_DIR" +else + echo "Virtual environment already exists at $VENV_DIR" fi - -rm -f dist/* - -python3.11 package.py +# Activate the virtual environment +source "$VENV_DIR/bin/activate" +# Ensure setuptools and wheel are installed +pip install --upgrade setuptools wheel build +# Build the distribution +python3 -m build +# Deactivate the virtual environment +deactivate diff --git a/azure-slurm-exporter/pyproject.toml b/azure-slurm-exporter/pyproject.toml new file mode 100644 index 00000000..230a3d4f --- /dev/null +++ b/azure-slurm-exporter/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "azure-slurm-exporter" +version = "0.1.0" +description = "Prometheus exporter for Azure CycleCloud Slurm metrics" +requires-python = ">=3.11" +license = "MIT" +authors = [ + { name = "Azure CycleCloud Team" }, +] +dependencies = [ + "prometheus_client", + "aiohttp" +] + +[project.optional-dependencies] +dev = [ + "pytest", +] + +[project.scripts] +azslurm-exporter = "exporter.main:main" + +[tool.setuptools.packages.find] +include = ["exporter*"] + +[tool.setuptools.package-data] +exporter = ["*.conf"] \ No newline at end of file diff --git a/azure-slurm-exporter/setup.py b/azure-slurm-exporter/setup.py deleted file mode 100644 index ebc799de..00000000 --- a/azure-slurm-exporter/setup.py +++ /dev/null @@ -1,137 +0,0 @@ -# test: ignore -import os -from subprocess import check_call -from typing import List - -from setuptools import find_packages, setup -from setuptools.command.test import Command -from setuptools.command.test import test as TestCommand # noqa: N812 - -__version__ = "4.0.6" -CWD = os.path.dirname(os.path.abspath(__file__)) - - -class PyTest(TestCommand): - def finalize_options(self) -> None: - TestCommand.finalize_options(self) - import os - - xml_out = os.path.join(".", "build", "test-results", "pytest.xml") - if not os.path.exists(os.path.dirname(xml_out)): - os.makedirs(os.path.dirname(xml_out)) - # -s is needed so py.test doesn't mess with stdin/stdout - self.test_args = ["-s", "test", "--junitxml=%s" % xml_out] - # needed for older setuptools to actually run this as a test - self.test_suite = True - - def run_tests(self) -> None: - # import here, cause outside the eggs aren't loaded - import sys - import pytest - - # run the tests, then the format checks. - errno = pytest.main(self.test_args) - if errno != 0: - sys.exit(errno) - - check_call( - ["black", "--check", "src", "test"], - cwd=CWD, - ) - check_call( - ["isort", "-c"], - cwd=os.path.join(CWD, "src"), - ) - check_call( - ["isort", "-c"], - cwd=os.path.join(CWD, "test"), - ) - - run_type_checking() - - sys.exit(errno) - - -class Formatter(Command): - user_options: List[str] = [] - - def initialize_options(self) -> None: - pass - - def finalize_options(self) -> None: - pass - - def run(self) -> None: - check_call( - ["black", "src", "test"], cwd=CWD, - ) - check_call( - ["isort", "-y"], - cwd=os.path.join(CWD, "src"), - ) - check_call( - ["isort", "-y"], - cwd=os.path.join(CWD, "test"), - ) - run_type_checking() - - -def run_type_checking() -> None: - check_call( - [ - "mypy", - "--ignore-missing-imports", - "--follow-imports=silent", - "--show-column-numbers", - "--disallow-untyped-defs", - os.path.join(CWD, "test"), - ] - ) - check_call( - [ - "mypy", - "--ignore-missing-imports", - "--follow-imports=silent", - "--show-column-numbers", - "--disallow-untyped-defs", - os.path.join(CWD, "src"), - ] - ) - - check_call(["flake8", "--ignore=E203,E231,F405,E501,W503", "src", "test", "setup.py"]) - - -class TypeChecking(Command): - user_options: List[str] = [] - - def initialize_options(self) -> None: - pass - - def finalize_options(self) -> None: - pass - - def run(self) -> None: - run_type_checking() - - -setup( - name="azure-slurm-exporter", - version=__version__, - packages=find_packages(), - #package_dir={"": "slurmcc"}, - package_data={ - "azure-slurm-exporter": [ - "BUILD_NUMBER", - "private-requirements.json", - "../NOTICE", - "../notices", - ] - }, - install_requires=["prometheus-client", "aiohttp"], - tests_require=["pytest==3.2.3"], - - cmdclass={"test": PyTest, "format": Formatter, "types": TypeChecking}, - url="http://www.cyclecomputing.com", - maintainer="Cycle Computing", - maintainer_email="support@cyclecomputing.com", -) From 5d373d78e68c7bd4f13dc416f9137968537c0748 Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Thu, 5 Mar 2026 16:37:14 -0500 Subject: [PATCH 20/22] add azslurm-exporter documentation to readme --- README.md | 92 ++++++++++++++++++++++++++------- images/azslurmexporterdash.png | Bin 0 -> 176754 bytes 2 files changed, 73 insertions(+), 19 deletions(-) create mode 100755 images/azslurmexporterdash.png diff --git a/README.md b/README.md index bb4b11e7..43c8c8db 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,14 @@ Slurm is a highly configurable open source workload manager. See the [Slurm proj 7. [Slurm Job Accounting](#slurm-job-accounting) 1. [Cost Reporting](#cost-reporting) 8. [Topology](#topology) - 9. [GB200/GB300 IMEX Support](#gb200gb300-imex-support) + 9. [GB200/GB300 IMEX Support](#gb200gb300-imex-support) 10. [Setting KeepAlive in CycleCloud](#setting-keepalive) 11. [Slurmrestd](#slurmrestd) 12. [Node Health Checks](#node-health-checks) 13. [Monitoring](#monitoring) - 1. [Example Dashboards](#example-dashboards) + 1. [AzSlurm Exporter](#azslurm-exporter) + 1. [Exported Metrics](#exported-metrics) + 2. [Example Dashboards](#example-dashboards) 2. [Supported Slurm and PMIX versions](#supported-slurm-and-pmix-versions) 3. [Packaging](#packaging) 1. [Supported OS and PMC Repos](#supported-os-and-pmc-repos) @@ -40,27 +42,27 @@ Slurm is a highly configurable open source workload manager. See the [Slurm proj ### Making Cluster Changes In CycleCloud, cluster changes can be made using the "Edit" dialog from the cluster page in the GUI or from the CycleCloud CLI. Cluster topology changes, such as new partitions, generally require editing and re-importing the cluster template. This can be applied to live, running clusters as well as terminated clusters. It is also possible to import changes as a new Template for future cluster creation via the GUI. - + When updating a running cluster, some changes may need to be applied directly on the running nodes. Slurm clusters deployed by CycleCloud include a cli, available on the scheduler node, called `azslurm` which facilitates applying cluster configuration and scaling changes for running clusters. - + After making any changes to the running cluster, run the following command as root on the Slurm scheduler node to rebuild the `azure.conf` and update the nodes in the cluster: - + ``` $ sudo -i # azslurm scale ``` This should create the partitions with the correct number of nodes, the proper `gres.conf` and restart the `slurmctld`. - + For changes that are not available via the cluster's "Edit" dialog in the GUI, the cluster template must be customized. First, download a copy of the [Slurm cluster template](#templates/slurm.txt), if you do not have it. Then, to make template changes for a cluster you can perform the following commands using the cyclecloud cli. ``` # First update a copy of the slurm template (shown as ./MODIFIED_SLURM.txt below) - + cyclecloud export_parameters MY_CLUSTERNAME > ./MY_CLUSTERNAME.json cyclecloud import_cluster MY_CLUSTERNAME -c slurm -f ./MODIFIED_slurm.txt -p ./MY_CLUSTERNAME.json --force ``` For a terminated cluster you can go ahead and start the cluster with all changes in effect. - + **IMPORTANT: There is no need to terminate the cluster or scale down to apply changes.** To apply changes to a running/started cluster perform the following steps after you have completed the previous steps: @@ -112,7 +114,7 @@ PartitionName=mydynamic Nodes=mydynamicns ``` #### Using Dynamic Partitions to Autoscale -By default, we define no nodes in the dynamic partition. +By default, we define no nodes in the dynamic partition. You can pre-create node records like so, which allows Slurm to autoscale them up. ```bash @@ -170,7 +172,7 @@ To shutdown nodes, run `/opt/azurehpc/slurm/suspend_program.sh node_list` (e.g. To start a cluster in this mode, simply add `SuspendTime=-1` to the additional slurm config in the template. -To switch a cluster to this mode, add `SuspendTime=-1` to the slurm.conf and run `scontrol reconfigure`. Then run `azslurm remove_nodes && azslurm scale`. +To switch a cluster to this mode, add `SuspendTime=-1` to the slurm.conf and run `scontrol reconfigure`. Then run `azslurm remove_nodes && azslurm scale`. ### Slurm Job Accounting @@ -185,7 +187,7 @@ To setup job accounting, following fields are defined in the slurm cluster creat - *Database URL* - What this refers to is the "Database" URL, a DNS resolvable address (or an IP address) of where mysql database lives. -- *Database Name* - This is the database name that the Slurm Cluster will use. If this is not defined, then this is "clustername-acct-db". +- *Database Name* - This is the database name that the Slurm Cluster will use. If this is not defined, then this is "clustername-acct-db". Each cluster typically (when not defined) has its own database. This helps to not cause roll ups between starting clusters of different slurm versions. - *Database User* - This refers to the username slurmdbd will use to connect to MySQL Server. - *Database Password* - This refers to the password slurmdbd will use to connect to MySQL Server. @@ -358,13 +360,13 @@ Cyclecloud Slurm clusters now include prolog and epilog scripts to enable and cl slurm.imex.enabled=True or slurm.imex.enabled=False -``` +``` ### Setting KeepAlive Added in 4.0.5: If the KeepAlive attribute is set in the CycleCloud UI, then the azslurmd will add that node's name to the `SuspendExcNodes` attribute via scontrol. Note that it is required that `ReconfigFlags=KeepPowerSaveSettings` is set in the slurm.conf, as is the default as of 4.0.5. Once KeepALive is set back to false, `azslurmd` will then remove this node from `SuspendExcNodes`. -If a node is added to `SuspendExcNodes` either via `azslurm keep_alive` or via the scontrol command, then `azslurmd` will not remove this node from the `SuspendExcNodes` if KeepAlive is false in CycleCloud. However, if the node is later set to KeepAlive as true in the UI then `azslurmd` will then remove it from `SuspendExcNodes` when the node is set back to KeepAlive is false. +If a node is added to `SuspendExcNodes` either via `azslurm keep_alive` or via the scontrol command, then `azslurmd` will not remove this node from the `SuspendExcNodes` if KeepAlive is false in CycleCloud. However, if the node is later set to KeepAlive as true in the UI then `azslurmd` will then remove it from `SuspendExcNodes` when the node is set back to KeepAlive is false. ### Slurmrestd As of version 4.0.5, `slurmrestd` is automatically configured and started on the scheduler node and scheduler-ha node for all Slurm clusters. This REST API service provides programmatic access to Slurm functionality, allowing external applications and tools to interact with the cluster. For more information on the Slurm REST API, see the [official Slurm REST API documentation](https://slurm.schedmd.com/rest_api.html). @@ -432,8 +434,60 @@ To check if the configured exporters are exposing metrics, connect to a node and - For the DCGM Exporter : `curl -s http://localhost:9400/metrics` - only available on VM type with NVidia GPU - For the Slurm Exporter : `curl -s http://localhost:9200/metrics` - only available on the Slurm scheduler VM +#### AzSlurm Exporter + +The AzSlurm Exporter is a lightweight, asynchronous Prometheus exporter that runs on the Slurm scheduler node as a systemd service and exposes Slurm cluster metrics on port `9101` at the `/metrics` endpoint. It periodically queries cluster available CLI tools (`squeue`, `sacct`, `sinfo`, `azslurm`, `jetpack`), parses their output, and publishes metrics in Prometheus format for ingestion by Azure Monitor or any Prometheus-compatible monitoring system. + +If a collector binary is unavailable, that collector is skipped with a warning. The exporter only exits if **no** collectors initialize successfully. + +Log can be found under `/var/log/azslurm-exporter.log` + +##### Exported Metrics + +**squeue metrics** + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `squeue_partition_jobs_state` | Gauge | `partition`, `state` | Number of jobs in each state per partition | +| `squeue_job_nodes_allocated` | Gauge | `job_id`, `job_name`, `partition`, `state` | Nodes allocated to each running job | + +**sacct metrics** + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `sacct_terminal_jobs` | Counter | `partition`, `exit_code`, `reason`, `state` | Cumulative count of completed/failed/cancelled jobs | + +Terminal states tracked: `completed`, `failed`, `cancelled`, `timeout`, `node_fail`, `preempted`, `out_of_memory`, `deadline`, `boot_fail`. Exit codes are mapped to human-readable reasons (e.g. `137:0` → `SIGKILL - Force killed`). + +**sinfo metrics** + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `sinfo_partition_nodes_state` | Gauge | `node_list`, `partition`, `state`, `reason` | Number of nodes in each state per partition | + +Node state suffixes (e.g. `*` = not responding, `~` = powered off, `#` = powering up) are normalized to descriptive state names. + +**azslurm metrics** + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `azslurm_partition_info` | Gauge | `partition`, `nodelist`, `vm_size`, `azure_count` | Available node count per partition. The gauge value is the `available_count` for the partition, and the `azure_count` label reflects the minimum of family and regional quota availability. | + +The `azslurm` collector queries `azslurm partitions` and `azslurm limits` to combine partition-to-nodelist mappings with Azure quota and VM availability information, providing visibility into how many nodes can actually be provisioned for each partition. + +**jetpack metrics** + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `jetpack_cluster_info` | Gauge | `region` | Cluster metadata exposing the Azure region where the cluster is deployed. Always set to `1` as an info-style metric. | + +The `jetpack` collector queries `jetpack config` to retrieve the Azure region from the VM's compute metadata. It runs infrequently (default: every 24 hours) since this value does not change during the lifetime of a cluster. + #### Example Dashboards +**AzSlurm Dashboard** +![Alt](/images/azslurmexporterdash.png "Example AzSlurm Exporter Grafana Dashboard") + **Slurm Dashboard** ![Alt](/images/slurmexporterdash.png "Example Slurm Exporter Grafana Dashboard") *Note: this dashboard is not published with cyclecloud-monitoring project and is used here as an example* @@ -538,7 +592,7 @@ Nov 18 17:51:58 rc403-hpc-1 slurmd[8046]: [2025-11-18T17:51:58.002] error: Secur For some regions and VM sizes, some subscriptions may report an incorrect number of GPUs. This value is controlled in `/opt/azurehpc/slurm/autoscale.json` -The default definition looks like the following: +The default definition looks like the following: ```json "default_resources": [ { @@ -575,7 +629,7 @@ Slurm requires that you define the amount of free memory, after OS/Applications To change this dampening, there are two options. 1) You can define `slurm.dampen_memory=X` where X is an integer percentage (5 == 5%) -2) Create a default_resource definition in the /opt/azurehpc/slurm/autoscale.json file. +2) Create a default_resource definition in the /opt/azurehpc/slurm/autoscale.json file. ```json "default_resources": [ { @@ -618,8 +672,8 @@ This will change the behavior of the `azslurm return_to_idle` command that is, b 3. `cyclecloud_slurm.sh` no longer exists. Instead there is the azslurm cli, which can be run as root. `azslurm` uses autocomplete. ```bash [root@scheduler ~]# azslurm - usage: - accounting_info - + usage: + accounting_info - buckets - Prints out autoscale bucket information, like limits etc config - Writes the effective autoscale config, after any preprocessing, to stdout connect - Tests connection to CycleCloud @@ -627,7 +681,7 @@ This will change the behavior of the `azslurm return_to_idle` command that is, b default_output_columns - Output what are the default output columns for an optional command. initconfig - Creates an initial autoscale config. Writes to stdout keep_alive - Add, remeove or set which nodes should be prevented from being shutdown. - limits - + limits - nodes - Query nodes partitions - Generates partition configuration refresh_autocomplete - Refreshes local autocomplete information for cluster specific resources and nodes. @@ -635,7 +689,7 @@ This will change the behavior of the `azslurm return_to_idle` command that is, b resume - Equivalent to ResumeProgram, starts and waits for a set of nodes. resume_fail - Equivalent to SuspendFailProgram, shutsdown nodes retry_failed_nodes - Retries all nodes in a failed state. - scale - + scale - shell - Interactive python shell with relevant objects in local scope. Use --script to run python scripts suspend - Equivalent to SuspendProgram, shutsdown nodes topology - Generates topology plugin configuration diff --git a/images/azslurmexporterdash.png b/images/azslurmexporterdash.png new file mode 100755 index 0000000000000000000000000000000000000000..0c1217e7fbdb82364893e43fc2fdd44c6caec0a8 GIT binary patch literal 176754 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 Date: Fri, 6 Mar 2026 11:47:51 -0500 Subject: [PATCH 21/22] add arg parser to configure host and port --- azure-slurm-exporter/exporter/exporter.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/azure-slurm-exporter/exporter/exporter.py b/azure-slurm-exporter/exporter/exporter.py index 6763ab56..624cb676 100644 --- a/azure-slurm-exporter/exporter/exporter.py +++ b/azure-slurm-exporter/exporter/exporter.py @@ -1,4 +1,5 @@ import asyncio +import argparse import logging import logging.config import signal @@ -188,6 +189,11 @@ async def start_http_server(self, host:str, port:int) -> web.AppRunner: async def main(): + parser = argparse.ArgumentParser(description="Azure Slurm Prometheus Exporter") + parser.add_argument("--port", type=int, default=9101, help="Port to expose metrics on (default: 9101)") + parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") + args = parser.parse_args() + conf_file = resources.files("exporter").joinpath("exporter_logging.conf") logging.config.fileConfig(str(conf_file)) @@ -205,7 +211,7 @@ async def main(): sys.exit(1) try: - runner = await collector.start_http_server(host="0.0.0.0", port=9101) + runner = await collector.start_http_server(host=args.host, port=args.port) except HTTPServerFailedException: sys.exit(1) From 53b9930a2164e1ca34b010f1c610695547388ccb Mon Sep 17 00:00:00 2001 From: Azreen Zaman Date: Fri, 6 Mar 2026 14:56:25 -0500 Subject: [PATCH 22/22] clean up some code --- azure-slurm-exporter/exporter/sacct.py | 3 +-- azure-slurm-exporter/exporter/sinfo.py | 3 +-- azure-slurm-exporter/exporter/squeue.py | 3 +-- azure-slurm-exporter/install.sh | 7 +++---- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/azure-slurm-exporter/exporter/sacct.py b/azure-slurm-exporter/exporter/sacct.py index 65c7c25e..719790d8 100644 --- a/azure-slurm-exporter/exporter/sacct.py +++ b/azure-slurm-exporter/exporter/sacct.py @@ -94,9 +94,8 @@ async def sacct_query(self) -> None: - The end time is set to the current moment when the query is executed - After execution, starttime is updated to the current endtime for the next query iteration """ - args = [] + args = [self.binary_path] self.endtime = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") - args.append(self.binary_path) args.extend(self.default_options) args.extend(["--starttime", self.starttime, "--endtime", self.endtime]) log.debug(f"running sacct query between {self.starttime} and {self.endtime}") diff --git a/azure-slurm-exporter/exporter/sinfo.py b/azure-slurm-exporter/exporter/sinfo.py index aa44809e..cb107dd8 100644 --- a/azure-slurm-exporter/exporter/sinfo.py +++ b/azure-slurm-exporter/exporter/sinfo.py @@ -100,7 +100,6 @@ async def sinfo_query(self) -> None: except Exception as e: log.error(e) return - output = self.parse_output(proc.stdout) #TODO: DO we need to lock this? - self.cached_output["sinfo_query"] = output + self.cached_output["sinfo_query"] = self.parse_output(proc.stdout) diff --git a/azure-slurm-exporter/exporter/squeue.py b/azure-slurm-exporter/exporter/squeue.py index 46e03824..dbe2d86f 100644 --- a/azure-slurm-exporter/exporter/squeue.py +++ b/azure-slurm-exporter/exporter/squeue.py @@ -117,6 +117,5 @@ async def squeue_query(self) -> None: except Exception as e: log.error(e) return - output = self.parse_output(proc.stdout) #TODO: DO we need to lock this? - self.cached_output["squeue_metrics"] = output + self.cached_output["squeue_metrics"] = self.parse_output(proc.stdout) diff --git a/azure-slurm-exporter/install.sh b/azure-slurm-exporter/install.sh index 3ca3da41..a1d3e640 100755 --- a/azure-slurm-exporter/install.sh +++ b/azure-slurm-exporter/install.sh @@ -39,8 +39,9 @@ setup_venv() { fi } + add_scraper() { - # If az_exporter is already configured, do not add it again + # If azslurm_exporter is already configured, do not add it again if grep -q "azslurm_exporter" $PROM_CONFIG; then echo "AzSlurm Exporter is already configured in Prometheus" return 0 @@ -90,14 +91,12 @@ EOF main() { VERSION=0.1.0 PACKAGE=azure_slurm_exporter-$VERSION.tar.gz - SCHEDULER=slurm VENV=/opt/azurehpc/azslurm-exporter/venv - INSTALL_DIR=$(dirname $VENV) - PATH=$PATH:/root/bin PROM_CONFIG=/opt/prometheus/prometheus.yml # create the venv and install azslurm-exporter setup_venv + #add azslurm-exporter scraper to prometheus.yml add_scraper # setup the azslurm-exporter systemd but do not start it. setup_azslurm_exporter