diff --git a/qa/1687 b/qa/1687 new file mode 100755 index 0000000000..939b18d2dd --- /dev/null +++ b/qa/1687 @@ -0,0 +1,115 @@ +#!/bin/sh +# PCP QA Test No. 1687 +# +# promdb2pcp basic conversion exerciser +# +# Copyright (c) 2026 Red Hat. All Rights Reserved. +# + +if [ $# -eq 0 ] +then + seq=`basename $0` + echo "QA output created by $seq" +else + # use $seq from caller, unless not set + [ -n "$seq" ] || seq=`basename $0` + echo "QA output created by `basename $0` $*" +fi + +# get standard environment, filters and checks +. ./common.product +. ./common.filter +. ./common.check + +. ./common.python + +$python -c 'from pcp import pmi' 2>/dev/null +test $? -eq 0 || _notrun "Python pcp pmi module is not installed" + +if which promdb2pcp >/dev/null 2>&1 +then + promdb2pcp=promdb2pcp +elif [ -f "$here/../src/promdb2pcp/promdb2pcp.py" ] +then + promdb2pcp="$python $here/../src/promdb2pcp/promdb2pcp.py" +else + _notrun "promdb2pcp not installed" +fi + +_cleanup() +{ + cd $here + $sudo rm -rf $tmp $tmp.* +} + +status=0 # success is the default! +trap "_cleanup; exit \$status" 0 1 2 3 15 + +_filter() +{ + _filter_pmdumplog \ + | sed \ + -e "s@$tmp@TMP@g" \ + # end +} + +# real QA test starts here + +echo "=== basic conversion ===" +rm -f $tmp.0 $tmp.index $tmp.meta +$promdb2pcp -a $tmp promdb2pcp +if [ $? -ne 0 ] +then + echo "promdb2pcp failed" + status=1 + exit +fi + +echo "=== archive check ===" +pmlogcheck $tmp + +echo +echo "=== metric descriptors ===" +pminfo -d -a $tmp \ + mem.physmem \ + mem.freemem \ + kernel.all.load \ + kernel.all.load15 \ + kernel.percpu.cpu.user \ + kernel.percpu.cpu.idle \ + disk.dev.read_bytes \ + disk.dev.write_bytes \ + network.interface.in.bytes \ + network.interface.out.bytes + +echo +echo "=== PMID structure (item IDs reset per cluster) ===" +pmdumplog -d $tmp 2>&1 \ +| grep 'PMID:' \ +| sed -e 's/ (.*//' \ +| sort -t. -k2,2n -k3,3n + +echo +echo "=== CPU metrics share same instance domain ===" +pminfo -d -a $tmp kernel.percpu.cpu.user kernel.percpu.cpu.idle \ +| grep InDom \ +| sort -u + +echo +echo "=== CPU instance names ===" +pmprobe -I -a $tmp kernel.percpu.cpu.user + +echo +echo "=== metric values (first timestamp) ===" +echo "--- mem.physmem (should be MemTotal/1024) ---" +pminfo -f -a $tmp -O +0 mem.physmem 2>&1 + +echo "--- kernel.all.load ---" +pminfo -f -a $tmp -O +0 kernel.all.load 2>&1 + +echo +echo "=== hostname from metadata ===" +pmdumplog -l $tmp 2>&1 | grep 'Performance metrics from host testhost.example.com' + +# success, all done +exit diff --git a/qa/1687.out b/qa/1687.out new file mode 100644 index 0000000000..ce1026f59a --- /dev/null +++ b/qa/1687.out @@ -0,0 +1,82 @@ +QA output created by 1687 +=== basic conversion === +=== archive check === + +=== metric descriptors === + +mem.physmem + Data Type: 64-bit unsigned int InDom: PM_INDOM_NULL 0xffffffff + Semantics: instant Units: Kbyte + +mem.freemem + Data Type: 64-bit unsigned int InDom: PM_INDOM_NULL 0xffffffff + Semantics: instant Units: Kbyte + +kernel.all.load + Data Type: float InDom: PM_INDOM_NULL 0xffffffff + Semantics: instant Units: none + +kernel.all.load15 + Data Type: float InDom: PM_INDOM_NULL 0xffffffff + Semantics: instant Units: none + +kernel.percpu.cpu.user + Data Type: 64-bit unsigned int InDom: 510.0 0x7f800000 + Semantics: counter Units: millisec + +kernel.percpu.cpu.idle + Data Type: 64-bit unsigned int InDom: 510.0 0x7f800000 + Semantics: counter Units: millisec + +disk.dev.read_bytes + Data Type: 64-bit unsigned int InDom: 510.1 0x7f800001 + Semantics: counter Units: Kbyte + +disk.dev.write_bytes + Data Type: 64-bit unsigned int InDom: 510.2 0x7f800002 + Semantics: counter Units: Kbyte + +network.interface.in.bytes + Data Type: 64-bit unsigned int InDom: 510.3 0x7f800003 + Semantics: counter Units: byte + +network.interface.out.bytes + Data Type: 64-bit unsigned int InDom: 510.4 0x7f800004 + Semantics: counter Units: byte + +=== PMID structure (item IDs reset per cluster) === +PMID: 510.0.0 +PMID: 510.0.1 +PMID: 510.1.0 +PMID: 510.1.1 +PMID: 510.2.0 +PMID: 510.2.1 +PMID: 510.2.2 +PMID: 510.2.3 +PMID: 510.2.4 +PMID: 510.2.5 +PMID: 510.2.6 +PMID: 510.2.7 +PMID: 510.3.0 +PMID: 510.3.1 +PMID: 510.4.0 +PMID: 510.4.1 + +=== CPU metrics share same instance domain === + Data Type: 64-bit unsigned int InDom: 510.0 0x7f800000 + +=== CPU instance names === +kernel.percpu.cpu.user 2 "cpu0" "cpu1" + +=== metric values (first timestamp) === +--- mem.physmem (should be MemTotal/1024) --- + +mem.physmem + value 8192000 +--- kernel.all.load --- + +kernel.all.load + value 0.5 + +=== hostname from metadata === +Performance metrics from host testhost.example.com diff --git a/qa/1699 b/qa/1699 new file mode 100755 index 0000000000..f9ec8f9de7 --- /dev/null +++ b/qa/1699 @@ -0,0 +1,108 @@ +#!/bin/sh +# PCP QA Test No. 1699 +# +# promdb2pcp edge case exerciser - malformed data, cpu-N labels, +# bad metadata, missing files +# +# Copyright (c) 2026 Red Hat. All Rights Reserved. +# + +if [ $# -eq 0 ] +then + seq=`basename $0` + echo "QA output created by $seq" +else + # use $seq from caller, unless not set + [ -n "$seq" ] || seq=`basename $0` + echo "QA output created by `basename $0` $*" +fi + +# get standard environment, filters and checks +. ./common.product +. ./common.filter +. ./common.check + +. ./common.python + +$python -c 'from pcp import pmi' 2>/dev/null +test $? -eq 0 || _notrun "Python pcp pmi module is not installed" + +if which promdb2pcp >/dev/null 2>&1 +then + promdb2pcp=promdb2pcp +elif [ -f "$here/../src/promdb2pcp/promdb2pcp.py" ] +then + promdb2pcp="$python $here/../src/promdb2pcp/promdb2pcp.py" +else + _notrun "promdb2pcp not installed" +fi + +_cleanup() +{ + cd $here + $sudo rm -rf $tmp $tmp.* +} + +status=0 # success is the default! +trap "_cleanup; exit \$status" 0 1 2 3 15 + +_filter() +{ + sed \ + -e "s@$tmp@TMP@g" \ + # end +} + +# real QA test starts here + +echo "=== malformed values and bad metadata ===" +rm -f $tmp.0 $tmp.index $tmp.meta +$promdb2pcp -v -a $tmp promdb2pcp/baddata 2>&1 | _filter +if [ -f $tmp.0 ] +then + echo "archive created successfully despite bad data" + + echo + echo "=== archive check ===" + pmlogcheck $tmp + + echo + echo "=== mem.physmem (malformed timestamp skipped) ===" + pminfo -f -a $tmp mem.physmem 2>&1 + + echo + echo "=== mem.freemem (non-numeric value skipped) ===" + pminfo -f -a $tmp mem.freemem 2>&1 + + echo + echo "=== CPU instances from cpu-N labels ===" + pmprobe -I -a $tmp kernel.percpu.cpu.user + + echo + echo "=== CPU metrics share same indom ===" + pminfo -d -a $tmp kernel.percpu.cpu.user kernel.percpu.cpu.idle \ + | grep InDom \ + | sort -u +else + echo "promdb2pcp failed to produce an archive" + status=1 +fi + +echo +echo "=== non-existent data directory ===" +rm -f $tmp.0 $tmp.index $tmp.meta +$promdb2pcp -a $tmp /no/such/directory >$tmp.out 2>&1 +echo "exit status: $?" +_filter <$tmp.out + +echo +echo "=== empty data directory ===" +mkdir -p $tmp.emptydir +rm -f $tmp.0 $tmp.index $tmp.meta +$promdb2pcp -a $tmp $tmp.emptydir >$tmp.out 2>&1 +echo "exit status: $?" +_filter <$tmp.out +rmdir $tmp.emptydir 2>/dev/null + +# success, all done +exit diff --git a/qa/1699.out b/qa/1699.out new file mode 100644 index 0000000000..9af34d7054 --- /dev/null +++ b/qa/1699.out @@ -0,0 +1,42 @@ +QA output created by 1699 +=== malformed values and bad metadata === +Warning: failed to parse promdb2pcp/baddata/metadata.json, using defaults +mem.physmem (2 samples) +mem.freemem (2 samples) +kernel.percpu.cpu.user (2 CPUs) +kernel.percpu.cpu.nice (2 CPUs) +kernel.percpu.cpu.system (2 CPUs) +kernel.percpu.cpu.idle (2 CPUs) +kernel.percpu.cpu.iowait (2 CPUs) +kernel.percpu.cpu.irq (2 CPUs) +kernel.percpu.cpu.softirq (2 CPUs) +kernel.percpu.cpu.steal (2 CPUs) +Writing 3 timestamps (10 metrics) +Wrote 3 timestamp records to TMP +archive created successfully despite bad data + +=== archive check === + +=== mem.physmem (malformed timestamp skipped) === + +mem.physmem + value 8192000 + +=== mem.freemem (non-numeric value skipped) === + +mem.freemem + value 3998343 + +=== CPU instances from cpu-N labels === +kernel.percpu.cpu.user 2 "cpu0" "cpu1" + +=== CPU metrics share same indom === + Data Type: 64-bit unsigned int InDom: 510.0 0x7f800000 + +=== non-existent data directory === +exit status: 1 +Not a directory: /no/such/directory + +=== empty data directory === +exit status: 1 +No metric data found in TMP.emptydir diff --git a/qa/group b/qa/group index 2d1b24ab8a..fa689c75da 100644 --- a/qa/group +++ b/qa/group @@ -2237,6 +2237,7 @@ suse 1684 python derive local 1685 dbpmda python pmda.chrony local 1686 pmda.chrony python local +1687 promdb2pcp pmimport local python libpcp_import 1688 pmieconf local 1689 pmproxy libpcp_web local 1690 pmseries local @@ -2248,6 +2249,7 @@ suse 1696 pmproxy valgrind local 1697 pmproxy valgrind local pmjson 1698 logutil local pmlogger_daily +1699 promdb2pcp pmimport local python libpcp_import 1700 pmda.bpftrace local python pmjson 1701 pmda.bpftrace local python 1702 pmda.bpftrace local python diff --git a/qa/promdb2pcp/baddata/cpu.json b/qa/promdb2pcp/baddata/cpu.json new file mode 100644 index 0000000000..492f4f4647 --- /dev/null +++ b/qa/promdb2pcp/baddata/cpu.json @@ -0,0 +1,6 @@ +{"status":"success","data":{"resultType":"matrix","result":[ + {"metric":{"__name__":"node_cpu_seconds_total","cpu":"cpu-0","mode":"user"},"values":[[1700000000,"100.5"],[1700000030,"110.2"],[1700000060,"120.8"]]}, + {"metric":{"__name__":"node_cpu_seconds_total","cpu":"cpu-0","mode":"idle"},"values":[[1700000000,"800.0"],[1700000030,"825.5"],[1700000060,"850.3"]]}, + {"metric":{"__name__":"node_cpu_seconds_total","cpu":"cpu-1","mode":"user"},"values":[[1700000000,"90.3"],[1700000030,"95.1"],[1700000060,"101.4"]]}, + {"metric":{"__name__":"node_cpu_seconds_total","cpu":"cpu-1","mode":"idle"},"values":[[1700000000,"810.5"],[1700000030,"835.0"],[1700000060,"860.2"]]} +]}} diff --git a/qa/promdb2pcp/baddata/memory.json b/qa/promdb2pcp/baddata/memory.json new file mode 100644 index 0000000000..9e5e4d9e04 --- /dev/null +++ b/qa/promdb2pcp/baddata/memory.json @@ -0,0 +1,4 @@ +{"status":"success","data":{"resultType":"matrix","result":[ + {"metric":{"__name__":"node_memory_MemTotal_bytes"},"values":[[1700000000,"8388608000"],["bad_timestamp","1234"],[1700000060,"8388608000"]]}, + {"metric":{"__name__":"node_memory_MemFree_bytes"},"values":[[1700000000,"not_a_number"],[1700000030,"4094304000"],[1700000060,"4094304000"]]} +]}} diff --git a/qa/promdb2pcp/baddata/metadata.json b/qa/promdb2pcp/baddata/metadata.json new file mode 100644 index 0000000000..4d8b39515c --- /dev/null +++ b/qa/promdb2pcp/baddata/metadata.json @@ -0,0 +1 @@ +{this is not valid json} diff --git a/qa/promdb2pcp/cpu.json b/qa/promdb2pcp/cpu.json new file mode 100644 index 0000000000..e3ba8d54ce --- /dev/null +++ b/qa/promdb2pcp/cpu.json @@ -0,0 +1,8 @@ +{"status":"success","data":{"resultType":"matrix","result":[ + {"metric":{"__name__":"node_cpu_seconds_total","cpu":"0","mode":"user"},"values":[[1700000000,"100.5"],[1700000030,"110.2"],[1700000060,"120.8"]]}, + {"metric":{"__name__":"node_cpu_seconds_total","cpu":"0","mode":"system"},"values":[[1700000000,"50.1"],[1700000030,"55.3"],[1700000060,"60.7"]]}, + {"metric":{"__name__":"node_cpu_seconds_total","cpu":"0","mode":"idle"},"values":[[1700000000,"800.0"],[1700000030,"825.5"],[1700000060,"850.3"]]}, + {"metric":{"__name__":"node_cpu_seconds_total","cpu":"1","mode":"user"},"values":[[1700000000,"90.3"],[1700000030,"95.1"],[1700000060,"101.4"]]}, + {"metric":{"__name__":"node_cpu_seconds_total","cpu":"1","mode":"system"},"values":[[1700000000,"40.2"],[1700000030,"44.8"],[1700000060,"49.1"]]}, + {"metric":{"__name__":"node_cpu_seconds_total","cpu":"1","mode":"idle"},"values":[[1700000000,"810.5"],[1700000030,"835.0"],[1700000060,"860.2"]]} +]}} diff --git a/qa/promdb2pcp/disk.json b/qa/promdb2pcp/disk.json new file mode 100644 index 0000000000..306652a6f4 --- /dev/null +++ b/qa/promdb2pcp/disk.json @@ -0,0 +1,4 @@ +{"status":"success","data":{"resultType":"matrix","result":[ + {"metric":{"__name__":"node_disk_read_bytes_total","device":"sda"},"values":[[1700000000,"1048576000"],[1700000030,"1148576000"],[1700000060,"1248576000"]]}, + {"metric":{"__name__":"node_disk_written_bytes_total","device":"sda"},"values":[[1700000000,"524288000"],[1700000030,"624288000"],[1700000060,"724288000"]]} +]}} diff --git a/qa/promdb2pcp/loadavg.json b/qa/promdb2pcp/loadavg.json new file mode 100644 index 0000000000..811bae7f6a --- /dev/null +++ b/qa/promdb2pcp/loadavg.json @@ -0,0 +1,4 @@ +{"status":"success","data":{"resultType":"matrix","result":[ + {"metric":{"__name__":"node_load1"},"values":[[1700000000,"0.5"],[1700000030,"1.2"],[1700000060,"0.8"]]}, + {"metric":{"__name__":"node_load15"},"values":[[1700000000,"0.3"],[1700000030,"0.4"],[1700000060,"0.35"]]} +]}} diff --git a/qa/promdb2pcp/memory.json b/qa/promdb2pcp/memory.json new file mode 100644 index 0000000000..d2f5013811 --- /dev/null +++ b/qa/promdb2pcp/memory.json @@ -0,0 +1,4 @@ +{"status":"success","data":{"resultType":"matrix","result":[ + {"metric":{"__name__":"node_memory_MemTotal_bytes"},"values":[[1700000000,"8388608000"],[1700000030,"8388608000"],[1700000060,"8388608000"]]}, + {"metric":{"__name__":"node_memory_MemFree_bytes"},"values":[[1700000000,"4194304000"],[1700000030,"4094304000"],[1700000060,"3994304000"]]} +]}} diff --git a/qa/promdb2pcp/metadata.json b/qa/promdb2pcp/metadata.json new file mode 100644 index 0000000000..b949b6315c --- /dev/null +++ b/qa/promdb2pcp/metadata.json @@ -0,0 +1,4 @@ +{ + "node": "testhost.example.com", + "timezone": "UTC" +} diff --git a/qa/promdb2pcp/network.json b/qa/promdb2pcp/network.json new file mode 100644 index 0000000000..564d0b4cfe --- /dev/null +++ b/qa/promdb2pcp/network.json @@ -0,0 +1,4 @@ +{"status":"success","data":{"resultType":"matrix","result":[ + {"metric":{"__name__":"node_network_receive_bytes_total","device":"eth0"},"values":[[1700000000,"100000000"],[1700000030,"200000000"],[1700000060,"300000000"]]}, + {"metric":{"__name__":"node_network_transmit_bytes_total","device":"eth0"},"values":[[1700000000,"50000000"],[1700000030,"60000000"],[1700000060,"70000000"]]} +]}} diff --git a/src/GNUmakefile b/src/GNUmakefile index c595161b88..b112b8b980 100644 --- a/src/GNUmakefile +++ b/src/GNUmakefile @@ -130,6 +130,7 @@ OTHER_SUBDIRS += \ telnet-probe \ collectl2pcp \ guidellm2pcp \ + promdb2pcp \ vllmbench2pcp \ iostat2pcp \ mrtg2pcp \ diff --git a/src/promdb2pcp/GNUmakefile b/src/promdb2pcp/GNUmakefile new file mode 100644 index 0000000000..23afb13066 --- /dev/null +++ b/src/promdb2pcp/GNUmakefile @@ -0,0 +1,43 @@ +# +# Copyright (c) 2025 Red Hat. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# + +TOPDIR = ../.. +include $(TOPDIR)/src/include/builddefs + +TARGET = promdb2pcp +MAN_SECTION = 1 +MAN_PAGES = $(TARGET).$(MAN_SECTION) +MAN_DEST = $(PCP_MAN_DIR)/man$(MAN_SECTION) + +default: $(TARGET).py $(MAN_PAGES) + +default: + +include $(BUILDRULES) + +install: default +ifeq "$(HAVE_PYTHON)" "true" + $(INSTALL) -m 755 $(TARGET).py $(PCP_BIN_DIR)/$(TARGET) + @$(INSTALL_MAN) +endif + +default_pcp: default + +install_pcp: install + +check:: $(TARGET).py + $(PYLINT) $^ + +check :: $(MAN_PAGES) + $(MANLINT) $^ diff --git a/src/promdb2pcp/promdb2pcp.1 b/src/promdb2pcp/promdb2pcp.1 new file mode 100644 index 0000000000..940d3315be --- /dev/null +++ b/src/promdb2pcp/promdb2pcp.1 @@ -0,0 +1,139 @@ +'\"macro stdmacro +.\" +.\" Copyright (C) 2025 Red Hat. +.\" +.\" This program is free software; you can redistribute it and/or modify it +.\" under the terms of the GNU General Public License as published by the +.\" Free Software Foundation; either version 2 of the License, or (at your +.\" option) any later version. +.\" +.\" This program is distributed in the hope that it will be useful, but +.\" WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +.\" or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +.\" for more details. +.\" +.\" +.TH PROMDB2PCP 1 "PCP" "Performance Co-Pilot" +.SH NAME +\f3promdb2pcp\f1 \- Prometheus node_exporter metrics importer +.SH SYNOPSIS +\fBpromdb2pcp\fP +[\fB\-hv\fP] +[\fB\-a\fP \fIarchive\fP] +[\fB\-H\fP \fIhostname\fP] +\fIdatadir\fP +.SH DESCRIPTION +.B promdb2pcp +is a performance metrics importer tool that creates a PCP archive +from Prometheus +.BR node_exporter (1) +metrics stored as JSON files. +.PP +The input +.I datadir +is a directory containing JSON files produced by the Prometheus +HTTP API +.B /api/v1/query_range +endpoint. +Each file contains time series data for a group of related metrics +(e.g.\& cpu.json, memory.json, disk.json, network.json). +.PP +The following metric groups are supported: +.TP 5 +.B cpu.json +Per-CPU, per-mode utilisation counters +(kernel.percpu.cpu.user, kernel.percpu.cpu.system, etc.) +.TP +.B memory.json +Physical memory, swap, page cache and slab statistics +(mem.physmem, mem.freemem, mem.util.*, etc.) +.TP +.B disk.json +Per-device read/write bytes and operation counts +(disk.dev.read, disk.dev.write, disk.dev.read_bytes, etc.) +.TP +.B network.json +Per-interface bytes, packets, errors and drops +(network.interface.in.bytes, network.interface.out.bytes, etc.) +.TP +.B loadavg.json +Load averages and process counts +(kernel.all.load, kernel.all.running, etc.) +.TP +.B scheduler.json +Context switches, interrupts and fork counts +(kernel.all.pswitch, kernel.all.intr, kernel.all.sysfork) +.TP +.B vmstat.json +Virtual memory page fault and paging counters +(mem.vmstat.pgfault, mem.vmstat.pgpgin, etc.) +.TP +.B pressure.json +PSI (Pressure Stall Information) counters for CPU, memory and I/O +(kernel.all.pressure.cpu.some.total, etc.) +.PP +An optional +.B metadata.json +file in the data directory can specify the hostname and timezone +for the archive header. +.PP +The resulting PCP archive can be analysed using standard PCP tools +such as +.BR pmrep (1), +.BR pcp2csv (1), +and +.BR pmchart (1). +.SH OPTIONS +The available command line options are: +.TP 5 +\fB\-a\fR \fIarchive\fR, \fB\-\-archive\fR=\fIarchive\fR +Specifies the name of the output PCP +.I archive +to be produced. +By default, an archive named +.B node_archive +is created in the input data directory. +.TP +\fB\-H\fR \fIhostname\fR, \fB\-\-hostname\fR=\fIhostname\fR +Sets the +.I hostname +that will be stamped into the PCP archive label header. +By default, the hostname from +.B metadata.json +is used, or +.B localhost +if not available. +.TP +\fB\-h\fR, \fB\-\-help\fR +Display usage message and exit. +.TP +\fB\-v\fR, \fB\-\-verbose\fR +Report progress as each metric group is identified and its values saved. +.SH EXAMPLES +Extract Prometheus metrics from an OpenShift cluster using the +Prometheus HTTP API, then convert to a PCP archive: +.PP +.nf +.ft CR +$ curl -sk -H "Authorization: Bearer $TOKEN" \\ + "$PROM_URL/api/v1/query_range" \\ + --data-urlencode 'query={__name__=~"node_cpu.*", instance="worker-0:9100"}' \\ + --data-urlencode "start=$START" \\ + --data-urlencode "end=$END" \\ + --data-urlencode "step=30s" > prom_data/cpu.json +$ promdb2pcp -v -a worker0 prom_data/ +$ pmrep -a worker0 -t 30s kernel.percpu.cpu +.ft P +.fi +.SH SEE ALSO +.BR PCPIntro (1), +.BR pminfo (1), +.BR pmrep (1), +.BR pcp2csv (1), +.BR pmchart (1), +.BR LOGARCHIVE (5), +and +.BR PMNS (5). + +.\" control lines for scripts/man-spell +.\" +ok+ promdb2pcp prom OCP PSI diff --git a/src/promdb2pcp/promdb2pcp.py b/src/promdb2pcp/promdb2pcp.py new file mode 100644 index 0000000000..6738034b70 --- /dev/null +++ b/src/promdb2pcp/promdb2pcp.py @@ -0,0 +1,630 @@ +#!/usr/bin/pmpython +# +# Copyright (C) 2025 Red Hat. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# Create a PCP archive from Prometheus node_exporter JSON data. +# +# Reads a directory of JSON files produced by the Prometheus HTTP API +# query_range endpoint (e.g. cpu.json, memory.json, disk.json) and +# converts them into a PCP archive for analysis with pmrep, pcp2csv, +# pmchart and other PCP tools. +# + +import argparse +import json +import os +import re +import sys + +from collections import defaultdict +from pcp import pmapi, pmi +from cpmapi import (PM_COUNT_ONE, PM_ID_NULL, PM_INDOM_NULL, + PM_SEM_COUNTER, PM_SEM_INSTANT, + PM_SPACE_BYTE, PM_SPACE_KBYTE, + PM_TIME_MSEC, PM_TIME_USEC, + PM_TYPE_FLOAT, PM_TYPE_U32, PM_TYPE_U64, + PM_TEXT_PMID, PM_TEXT_ONELINE) + +DOMAIN = 510 +CPU_MODES = ['user', 'nice', 'system', 'idle', 'iowait', + 'irq', 'softirq', 'steal'] + +# Source JSON files and the Prometheus metrics they contain +SOURCE_FILES = { + 'memory.json': [ + ('node_memory_MemTotal_bytes', 'mem.physmem', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Total installed physical memory'), + ('node_memory_MemFree_bytes', 'mem.freemem', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Free physical memory'), + ('node_memory_MemAvailable_bytes', 'mem.util.available', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Available memory for starting new applications'), + ('node_memory_Buffers_bytes', 'mem.util.bufmem', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Memory used for kernel buffers'), + ('node_memory_Cached_bytes', 'mem.util.cached', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Memory used for page cache'), + ('node_memory_Active_bytes', 'mem.util.active', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Memory recently used and not reclaimed'), + ('node_memory_Inactive_bytes', 'mem.util.inactive', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Memory not recently used, eligible for reclaim'), + ('node_memory_Dirty_bytes', 'mem.util.dirty', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Memory waiting to be written back to disk'), + ('node_memory_Slab_bytes', 'mem.util.slab', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Kernel slab allocator memory'), + ('node_memory_SwapTotal_bytes', 'mem.util.swapTotal', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Total swap space'), + ('node_memory_SwapFree_bytes', 'mem.util.swapFree', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Free swap space'), + ('node_memory_AnonPages_bytes', 'mem.util.anonpages', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Memory in anonymous pages'), + ('node_memory_Mapped_bytes', 'mem.util.mapped', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Memory mapped with mmap'), + ('node_memory_Shmem_bytes', 'mem.util.shmem', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Shared memory (shmem and tmpfs)'), + ('node_memory_Committed_AS_bytes', 'mem.util.committed_AS', + PM_SEM_INSTANT, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Total committed address space'), + ], + 'loadavg.json': [ + ('node_load1', 'kernel.all.load', + PM_SEM_INSTANT, (0, 0, 0, 0, 0, 0), + PM_TYPE_FLOAT, 1, + 'One-minute load average'), + ('node_load5', 'kernel.all.load5', + PM_SEM_INSTANT, (0, 0, 0, 0, 0, 0), + PM_TYPE_FLOAT, 1, + 'Five-minute load average'), + ('node_load15', 'kernel.all.load15', + PM_SEM_INSTANT, (0, 0, 0, 0, 0, 0), + PM_TYPE_FLOAT, 1, + 'Fifteen-minute load average'), + ('node_procs_running', 'kernel.all.running', + PM_SEM_INSTANT, (0, 0, 0, 0, 0, 0), + PM_TYPE_U32, 1, + 'Number of currently running processes'), + ('node_procs_blocked', 'kernel.all.blocked', + PM_SEM_INSTANT, (0, 0, 0, 0, 0, 0), + PM_TYPE_U32, 1, + 'Number of processes blocked on I/O'), + ], + 'scheduler.json': [ + ('node_context_switches_total', 'kernel.all.pswitch', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Context switches since boot'), + ('node_intr_total', 'kernel.all.intr', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Interrupt count since boot'), + ('node_forks_total', 'kernel.all.sysfork', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Number of fork() calls since boot'), + ], + 'vmstat.json': [ + ('node_vmstat_pgfault', 'mem.vmstat.pgfault', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Page faults since boot'), + ('node_vmstat_pgmajfault', 'mem.vmstat.pgmajfault', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Major page faults since boot'), + ('node_vmstat_pgpgin', 'mem.vmstat.pgpgin', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Pages paged in from disk'), + ('node_vmstat_pgpgout', 'mem.vmstat.pgpgout', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Pages paged out to disk'), + ('node_vmstat_pswpin', 'mem.vmstat.pswpin', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Pages swapped in'), + ('node_vmstat_pswpout', 'mem.vmstat.pswpout', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Pages swapped out'), + ('node_vmstat_oom_kill', 'mem.vmstat.oom_kill', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'OOM killer invocations'), + ], + 'pressure.json': [ + ('node_pressure_cpu_waiting_seconds_total', + 'kernel.all.pressure.cpu.some.total', + PM_SEM_COUNTER, (0, 1, 0, 0, PM_TIME_USEC, 0), + PM_TYPE_U64, 1e-6, + 'CPU pressure stall time (some)'), + ('node_pressure_memory_waiting_seconds_total', + 'kernel.all.pressure.memory.some.total', + PM_SEM_COUNTER, (0, 1, 0, 0, PM_TIME_USEC, 0), + PM_TYPE_U64, 1e-6, + 'Memory pressure stall time (some)'), + ('node_pressure_memory_stalled_seconds_total', + 'kernel.all.pressure.memory.full.total', + PM_SEM_COUNTER, (0, 1, 0, 0, PM_TIME_USEC, 0), + PM_TYPE_U64, 1e-6, + 'Memory pressure stall time (full)'), + ('node_pressure_io_waiting_seconds_total', + 'kernel.all.pressure.io.some.total', + PM_SEM_COUNTER, (0, 1, 0, 0, PM_TIME_USEC, 0), + PM_TYPE_U64, 1e-6, + 'I/O pressure stall time (some)'), + ('node_pressure_io_stalled_seconds_total', + 'kernel.all.pressure.io.full.total', + PM_SEM_COUNTER, (0, 1, 0, 0, PM_TIME_USEC, 0), + PM_TYPE_U64, 1e-6, + 'I/O pressure stall time (full)'), + ], +} + +DISK_METRICS = [ + ('node_disk_read_bytes_total', 'disk.dev.read_bytes', + PM_SEM_COUNTER, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Bytes read from each disk device'), + ('node_disk_written_bytes_total', 'disk.dev.write_bytes', + PM_SEM_COUNTER, (1, 0, 0, PM_SPACE_KBYTE, 0, 0), + PM_TYPE_U64, 1024, + 'Bytes written to each disk device'), + ('node_disk_reads_completed_total', 'disk.dev.read', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Read operations completed per disk device'), + ('node_disk_writes_completed_total', 'disk.dev.write', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Write operations completed per disk device'), + ('node_disk_io_time_seconds_total', 'disk.dev.avactive', + PM_SEM_COUNTER, (0, 1, 0, 0, PM_TIME_MSEC, 0), + PM_TYPE_U64, 0.001, + 'Time spent doing I/O per disk device'), +] + +NET_METRICS = [ + ('node_network_receive_bytes_total', 'network.interface.in.bytes', + PM_SEM_COUNTER, (1, 0, 0, PM_SPACE_BYTE, 0, 0), + PM_TYPE_U64, 1, + 'Bytes received per network interface'), + ('node_network_transmit_bytes_total', 'network.interface.out.bytes', + PM_SEM_COUNTER, (1, 0, 0, PM_SPACE_BYTE, 0, 0), + PM_TYPE_U64, 1, + 'Bytes transmitted per network interface'), + ('node_network_receive_packets_total', 'network.interface.in.packets', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Packets received per network interface'), + ('node_network_transmit_packets_total', 'network.interface.out.packets', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Packets transmitted per network interface'), + ('node_network_receive_errs_total', 'network.interface.in.errors', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Receive errors per network interface'), + ('node_network_transmit_errs_total', 'network.interface.out.errors', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Transmit errors per network interface'), + ('node_network_receive_drop_total', 'network.interface.in.drops', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Packets dropped on receive per network interface'), + ('node_network_transmit_drop_total', 'network.interface.out.drops', + PM_SEM_COUNTER, (0, 0, 1, 0, 0, PM_COUNT_ONE), + PM_TYPE_U64, 1, + 'Packets dropped on transmit per network interface'), +] + + +def load_json(filepath): + """ Load a Prometheus query_range API JSON response file. """ + if not os.path.exists(filepath): + return None + try: + with open(filepath) as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None + if data.get('status') != 'success': + return None + return data.get('data', {}).get('result', []) + + +def discover_instances(results, label_key): + """ Find all unique instance labels (device names, cpu numbers). """ + instances = set() + for r in results: + val = r['metric'].get(label_key, '') + if val: + instances.add(val) + return sorted(instances) + + +def build_indexed_data(results, label_key): + """ Build {prom_metric: {instance_label: [(ts, value), ...]}}. """ + data = defaultdict(lambda: defaultdict(list)) + for r in results: + name = r['metric'].get('__name__', '') + inst = r['metric'].get(label_key, '') + for ts, val in r.get('values', []): + data[name][inst].append((float(ts), float(val))) + return data + + +def register_simple_metrics(log, data_dir, verbose): + """ Register and load non-instanced metrics from source JSON files. """ + cluster = 0 + item = 0 + all_timestamps = set() + metric_values = {} + + for src_file, entries in SOURCE_FILES.items(): + results = load_json(os.path.join(data_dir, src_file)) + if not results: + continue + + results_by_name = defaultdict(list) + for r in results: + name = r['metric'].get('__name__', '') + if name: + results_by_name[name].append(r) + + for entry in entries: + (prom_name, pcp_name, sem, units_args, + pcp_type, divisor, helptext) = entry + + matching = results_by_name.get(prom_name) + if not matching: + continue + if len(matching) > 1: + print('Warning: %s has %d matching series, using first' + % (prom_name, len(matching)), file=sys.stderr) + + r = matching[0] + units = pmapi.pmUnits(*units_args) + pmid = log.pmiID(DOMAIN, cluster, item) + + try: + log.pmiAddMetric(pcp_name, pmid, pcp_type, + PM_INDOM_NULL, sem, units) + log.pmiPutText(PM_TEXT_PMID, PM_TEXT_ONELINE, + pmid, helptext) + except pmi.pmiErr as err: + print(f"PCP metric registration failed for {pcp_name}: {err}", file=sys.stderr) + continue + + item += 1 + values = {} + for ts, val in r.get('values', []): + try: + fts = float(ts) + fval = float(val) + if divisor != 1: + fval = int(fval / divisor) + elif pcp_type in (PM_TYPE_U32, PM_TYPE_U64): + fval = int(fval) + except (TypeError, ValueError): + continue + values[fts] = fval + all_timestamps.add(fts) + + metric_values[pcp_name] = { + 'instances': {'': values}, 'type': pcp_type + } + if verbose: + print('%s (%d samples)' % (pcp_name, len(values))) + + cluster += 1 + item = 0 + + return all_timestamps, metric_values, cluster, item + + +def cpu_id(label): + """ Extract integer CPU ID from labels like '0', 'cpu0', or 'cpu-0'. """ + m = re.search(r'(\d+)$', label) + return int(m.group(1)) if m else -1 + + +def register_cpu_metrics(log, data_dir, cluster, verbose): + """ Register per-cpu per-mode metrics from cpu.json. """ + all_timestamps = set() + metric_values = {} + serial = 0 + + cpu_results = load_json(os.path.join(data_dir, 'cpu.json')) + if not cpu_results: + return all_timestamps, metric_values, cluster, serial + + cpu_numbers = set() + cpu_data = defaultdict(list) + + for r in cpu_results: + if r['metric'].get('__name__') != 'node_cpu_seconds_total': + continue + cpu_num = r['metric'].get('cpu', '0') + mode = r['metric'].get('mode', 'unknown') + if mode not in CPU_MODES: + continue + cpu_numbers.add(cpu_num) + for ts, val in r.get('values', []): + cpu_data[(cpu_num, mode)].append((float(ts), float(val))) + + cpu_numbers = sorted(cpu_numbers, key=cpu_id) + item = 0 + helptext = { + 'user': 'User mode CPU time per processor', + 'nice': 'Nice priority CPU time per processor', + 'system': 'System mode CPU time per processor', + 'idle': 'Idle CPU time per processor', + 'iowait': 'I/O wait CPU time per processor', + 'irq': 'Hardware interrupt CPU time per processor', + 'softirq': 'Software interrupt CPU time per processor', + 'steal': 'Stolen CPU time per processor (virtualisation)', + } + + indom = log.pmiInDom(DOMAIN, serial) + for cpu_num in cpu_numbers: + try: + log.pmiAddInstance(indom, 'cpu' + str(cpu_id(cpu_num)), + cpu_id(cpu_num)) + except pmi.pmiErr: + pass + + for mode in CPU_MODES: + pcp_name = 'kernel.percpu.cpu.' + mode + units = pmapi.pmUnits(0, 1, 0, 0, PM_TIME_MSEC, 0) + pmid = log.pmiID(DOMAIN, cluster, item) + item += 1 + + try: + log.pmiAddMetric(pcp_name, pmid, PM_TYPE_U64, + indom, PM_SEM_COUNTER, units) + log.pmiPutText(PM_TEXT_PMID, PM_TEXT_ONELINE, + pmid, helptext.get(mode, '')) + except pmi.pmiErr: + pass + + instances = {} + for cpu_num in cpu_numbers: + values = {} + for ts, val in cpu_data.get((cpu_num, mode), []): + values[ts] = int(float(val) * 1000) + all_timestamps.add(ts) + instances['cpu' + str(cpu_id(cpu_num))] = values + + metric_values[pcp_name] = { + 'instances': instances, 'type': PM_TYPE_U64 + } + if verbose: + print('%s (%d CPUs)' % (pcp_name, len(cpu_numbers))) + + return all_timestamps, metric_values, cluster + 1, serial + 1 + + +def register_device_metrics(log, data_dir, metric_list, src_file, + label_key, cluster, serial, verbose): + """ Register per-device instanced metrics (disk, network). """ + all_timestamps = set() + metric_values = {} + + results = load_json(os.path.join(data_dir, src_file)) + if not results: + return all_timestamps, metric_values, cluster, serial + + indexed = build_indexed_data(results, label_key) + devices = discover_instances(results, label_key) + item = 0 + + for entry in metric_list: + (prom_name, pcp_name, sem, units_args, + pcp_type, divisor, helptext) = entry + + if prom_name not in indexed: + continue + + units = pmapi.pmUnits(*units_args) + indom = log.pmiInDom(DOMAIN, serial) + serial += 1 + pmid = log.pmiID(DOMAIN, cluster, item) + item += 1 + + try: + log.pmiAddMetric(pcp_name, pmid, pcp_type, + indom, sem, units) + log.pmiPutText(PM_TEXT_PMID, PM_TEXT_ONELINE, + pmid, helptext) + except pmi.pmiErr: + pass + + for i, dev in enumerate(devices): + try: + log.pmiAddInstance(indom, dev, i) + except pmi.pmiErr: + pass + + instances = {} + for dev in devices: + values = {} + for ts, val in indexed[prom_name].get(dev, []): + if divisor != 1: + values[ts] = int(val / divisor) + else: + values[ts] = int(val) + all_timestamps.add(ts) + instances[dev] = values + + metric_values[pcp_name] = { + 'instances': instances, 'type': pcp_type + } + if verbose: + print('%s (%d %ss)' % (pcp_name, len(devices), label_key)) + + return all_timestamps, metric_values, cluster + 1, serial + + +def convert(data_dir, archive_name, hostname, verbose): + """ Convert Prometheus JSON data directory to a PCP archive. """ + log = pmi.pmiLogImport(archive_name) + + meta_file = os.path.join(data_dir, 'metadata.json') + meta = None + if os.path.exists(meta_file): + try: + with open(meta_file) as f: + meta = json.load(f) + except (OSError, json.JSONDecodeError): + print('Warning: failed to parse %s, using defaults' + % meta_file, file=sys.stderr) + + if meta: + host = meta.get('node', hostname) + log.pmiSetHostname(host) + tz_str = meta.get('timezone', 'UTC') + try: + log.pmiSetTimezone(tz_str) + except Exception: + log.pmiSetTimezone('UTC') + else: + log.pmiSetHostname(hostname) + log.pmiSetTimezone('UTC') + + all_timestamps = set() + metric_values = {} + + ts, mv, cluster, item = register_simple_metrics( + log, data_dir, verbose) + all_timestamps.update(ts) + metric_values.update(mv) + + serial = 0 + ts, mv, cluster, serial = register_cpu_metrics( + log, data_dir, cluster, verbose) + all_timestamps.update(ts) + metric_values.update(mv) + + ts, mv, cluster, serial = register_device_metrics( + log, data_dir, DISK_METRICS, 'disk.json', + 'device', cluster, serial, verbose) + all_timestamps.update(ts) + metric_values.update(mv) + + ts, mv, cluster, serial = register_device_metrics( + log, data_dir, NET_METRICS, 'network.json', + 'device', cluster, serial, verbose) + all_timestamps.update(ts) + metric_values.update(mv) + + if not all_timestamps: + print('No metric data found in', data_dir, file=sys.stderr) + sys.exit(1) + + sorted_timestamps = sorted(all_timestamps) + if verbose: + print('Writing %d timestamps (%d metrics)' % + (len(sorted_timestamps), len(metric_values))) + + written = 0 + for ts in sorted_timestamps: + sec = int(ts) + usec = int((ts - sec) * 1000000) + has_data = False + + for pcp_name, mdata in metric_values.items(): + pcp_type = mdata['type'] + for inst_name, inst_values in mdata['instances'].items(): + if ts not in inst_values: + continue + has_data = True + val = inst_values[ts] + inst_arg = inst_name if inst_name else '' + try: + if pcp_type == PM_TYPE_FLOAT: + log.pmiPutValue(pcp_name, inst_arg, + str(float(val))) + else: + log.pmiPutValue(pcp_name, inst_arg, + str(int(val))) + except pmi.pmiErr: + pass + + if has_data: + try: + log.pmiWrite(sec, usec) + written += 1 + except pmi.pmiErr as e: + print('Write failed at ts=%d: %s' % (sec, e), + file=sys.stderr) + + del log + if verbose: + print('Wrote %d timestamp records to %s' % + (written, archive_name)) + + +parser = argparse.ArgumentParser( + description='Import Prometheus node_exporter metrics into ' + 'a PCP archive') +parser.add_argument('-v', '--verbose', + help='Enable verbose progress diagnostics', + action='store_true') +parser.add_argument('-a', '--archive', + help='PCP archive output name ' + '(default: /node_archive)') +parser.add_argument('-H', '--hostname', default='localhost', + help='PCP archive hostname ' + '(default: from metadata.json or localhost)') +parser.add_argument('datadir', + help='Directory containing Prometheus JSON files') +args = parser.parse_args() + +if not os.path.isdir(args.datadir): + print('Not a directory:', args.datadir, file=sys.stderr) + sys.exit(1) + +archive = args.archive +if not archive: + archive = os.path.join(args.datadir, 'node_archive') + +convert(args.datadir, archive, args.hostname, args.verbose)