diff -Naurp pcp-6.3.7.orig/qa/1976 pcp-6.3.7/qa/1976 --- pcp-6.3.7.orig/qa/1976 1970-01-01 10:00:00.000000000 +1000 +++ pcp-6.3.7/qa/1976 2025-06-26 18:02:39.313416612 +1000 @@ -0,0 +1,85 @@ +#!/bin/sh +# PCP QA Test No. 1976 +# Test pmdaopenmetrics metric removal +# +# Copyright (c) 2017, 2025 Red Hat. All Rights Reserved. +# +seq=`basename $0` +echo "QA output created by $seq" + +# get standard environment, filters and checks +. ./common.openmetrics + +_pmdaopenmetrics_check || _notrun "openmetrics pmda not installed" + +status=1 # failure is the default! + +_cleanup() +{ + cd $here + _pmdaopenmetrics_cleanup + $sudo rm -rf $tmp $tmp.* +} + +_prepare_pmda openmetrics +trap "_cleanup; exit \$status" 0 1 2 3 15 +_stop_auto_restart pmcd + +_pmdaopenmetrics_save_config + +# add all the sample text files as urls. +# need to be a place the user $PCP_USER (pmcd) can read +# +( cd $here/openmetrics/samples; ls -1 *.txt ) | sort | while read file +do + cp $here/openmetrics/samples/$file $tmp.$file + urlbase=`basename "$file" .txt | tr .- _` + echo 'file://'$tmp.$file >$tmp.tmp + $sudo cp $tmp.tmp $PCP_PMDAS_DIR/openmetrics/config.d/$urlbase.url +done +ls -l $PCP_PMDAS_DIR/openmetrics/config.d >>$seq_full + +# add all the sample scripts +cp -a $here/openmetrics/scripts/* $PCP_PMDAS_DIR/openmetrics/config.d +find $PCP_PMDAS_DIR/openmetrics/config.d -name GNU\* -exec rm -f {} ";" + +_pmdaopenmetrics_install + +if ! _pmdaopenmetrics_wait_for_metric openmetrics.thermostat +then + status=1 + exit +fi + +echo "-- metric removal of new source/metric --" +$sudo rm $PCP_PMDAS_DIR/openmetrics/config.d/simple_metric.url +pminfo openmetrics.simple_metric +echo + +echo "-- source re-addition --" +# same access controls logic as above, user $PCP_USER needs to be +# able to read the file at the end of the URL +# +cp $here/openmetrics/samples/simple_metric.txt $tmp.simple_metric.txt +echo 'file:///'$tmp.simple_metric.txt >$PCP_PMDAS_DIR/openmetrics/config.d/simple_metric.url +pminfo openmetrics.simple_metric +echo + +echo "-- metric removal of recognized source/metric --" +$sudo rm $PCP_PMDAS_DIR/openmetrics/config.d/simple_metric.url +pminfo openmetrics.simple_metric +echo + +echo "-- source re-addition with epoch timestamp --" +txtpath=$here/openmetrics/samples/simple_metric.txt +urlfile=$PCP_PMDAS_DIR/openmetrics/config.d/simple_metric.url +echo 'file:///'$txtpath > $urlfile +$sudo touch -t 197001010000 $urlfile +pminfo openmetrics.simple_metric +echo + +_pmdaopenmetrics_remove >/dev/null 2>&1 + +# success, all done +status=0 +exit diff -Naurp pcp-6.3.7.orig/qa/1976.out pcp-6.3.7/qa/1976.out --- pcp-6.3.7.orig/qa/1976.out 1970-01-01 10:00:00.000000000 +1000 +++ pcp-6.3.7/qa/1976.out 2025-06-26 18:02:01.879861640 +1000 @@ -0,0 +1,17 @@ +QA output created by 1976 + +=== openmetrics agent installation === +-- metric removal of new source/metric -- +Error: openmetrics.simple_metric: Unknown metric name + +-- source re-addition -- +openmetrics.simple_metric.metric2 +openmetrics.simple_metric.metric1 + +-- metric removal of recognized source/metric -- +Error: openmetrics.simple_metric: Unknown metric name + +-- source re-addition with epoch timestamp -- +openmetrics.simple_metric.metric2 +openmetrics.simple_metric.metric1 + diff -Naurp pcp-6.3.7.orig/qa/group pcp-6.3.7/qa/group --- pcp-6.3.7.orig/qa/group 2025-06-26 17:55:37.790462638 +1000 +++ pcp-6.3.7/qa/group 2025-06-26 17:58:18.996389423 +1000 @@ -2205,6 +2205,7 @@ pmcd.pdu 1963 pmda.linux local 1970 pmda.bpf local 1973 pcp zoneinfo python local +1976 pmdaopenmetrics python local 1978 atop local pmlogrewrite 1984 pmlogconf pmda.redis local 1985 pmfind local valgrind diff -Naurp pcp-6.3.7.orig/qa/openmetrics/scripts/curl/script.sh pcp-6.3.7/qa/openmetrics/scripts/curl/script.sh --- pcp-6.3.7.orig/qa/openmetrics/scripts/curl/script.sh 2023-11-16 17:51:39.000000000 +1100 +++ pcp-6.3.7/qa/openmetrics/scripts/curl/script.sh 2025-06-26 18:02:50.155355973 +1000 @@ -1,4 +1,4 @@ #! /bin/sh . /etc/pcp.conf -curl -Gq file://$PCP_PMDAS_DIR/openmetrics/config.d/some_metric.txt +curl -Gqs file://$PCP_PMDAS_DIR/openmetrics/config.d/some_metric.txt diff -Naurp pcp-6.3.7.orig/qa/openmetrics/scripts/curl_filtered.sh pcp-6.3.7/qa/openmetrics/scripts/curl_filtered.sh --- pcp-6.3.7.orig/qa/openmetrics/scripts/curl_filtered.sh 2023-11-16 17:51:39.000000000 +1100 +++ pcp-6.3.7/qa/openmetrics/scripts/curl_filtered.sh 2025-06-26 18:02:50.155694515 +1000 @@ -7,6 +7,6 @@ . /etc/pcp.conf -( curl -Gq file://$PCP_PMDAS_DIR/openmetrics/config.d/some_metric.txt ; \ -curl -Gq file://$PCP_PMDAS_DIR/openmetrics/config.d/some_other_metric.txt) \ +( curl -Gqs file://$PCP_PMDAS_DIR/openmetrics/config.d/some_metric.txt ; \ +curl -Gqs file://$PCP_PMDAS_DIR/openmetrics/config.d/some_other_metric.txt) \ | sed -e '/metric2/d' diff -Naurp pcp-6.3.7.orig/qa/openmetrics/scripts/curl_hostname_label.sh pcp-6.3.7/qa/openmetrics/scripts/curl_hostname_label.sh --- pcp-6.3.7.orig/qa/openmetrics/scripts/curl_hostname_label.sh 2023-11-16 17:51:39.000000000 +1100 +++ pcp-6.3.7/qa/openmetrics/scripts/curl_hostname_label.sh 2025-06-26 18:02:50.155942641 +1000 @@ -12,5 +12,5 @@ # here for QA purposes we're fetching from a local file # and just pretending it came from a remote host. -curl -q -G file://$PCP_PMDAS_DIR/openmetrics/config.d/some_metric.txt 2>/dev/null | \ +curl -Gqs file://$PCP_PMDAS_DIR/openmetrics/config.d/some_metric.txt 2>/dev/null | \ sed -e 's/[a-z0-9]*=/hostname="remotehost",&/' diff -Naurp pcp-6.3.7.orig/qa/openmetrics/scripts/curl_scripted.sh pcp-6.3.7/qa/openmetrics/scripts/curl_scripted.sh --- pcp-6.3.7.orig/qa/openmetrics/scripts/curl_scripted.sh 2023-11-16 17:51:39.000000000 +1100 +++ pcp-6.3.7/qa/openmetrics/scripts/curl_scripted.sh 2025-06-26 18:02:50.156229975 +1000 @@ -1,5 +1,5 @@ #! /bin/sh . /etc/pcp.conf -curl -Gq file://$PCP_PMDAS_DIR/openmetrics/config.d/some_metric.txt -curl -Gq file://$PCP_PMDAS_DIR/openmetrics/config.d/some_other_metric.txt +curl -Gqs file://$PCP_PMDAS_DIR/openmetrics/config.d/some_metric.txt +curl -Gqs file://$PCP_PMDAS_DIR/openmetrics/config.d/some_other_metric.txt diff -Naurp pcp-6.3.7.orig/src/pmdas/openmetrics/pmdaopenmetrics.python pcp-6.3.7/src/pmdas/openmetrics/pmdaopenmetrics.python --- pcp-6.3.7.orig/src/pmdas/openmetrics/pmdaopenmetrics.python 2025-06-26 17:55:37.790973098 +1000 +++ pcp-6.3.7/src/pmdas/openmetrics/pmdaopenmetrics.python 2025-06-26 18:02:26.571388227 +1000 @@ -32,7 +32,8 @@ import subprocess import sys from ctypes import c_int from socket import gethostname -from stat import ST_MODE, S_IXUSR, ST_CTIME +from urllib.parse import urlparse +from stat import ST_MODE, S_IXUSR import requests from pcp.pmapi import pmUnits, pmContext @@ -127,15 +128,9 @@ class Metric(object): (name, pmContext.pmIDStr(self.pmid), self.mtype, self.msem, self.singular, self.mindom, self.labels)) self.obj = pmdaMetric(self.pmid, self.mtype, self.mindom, self.msem, self.munits) + self.source.pmda.all_metrics[self.mname] = self.obj - if helpline: # it could be None! - unescaped = helpline.replace('\\\\', '\\').replace('\\n', '\n') - split = unescaped.split('\n') - help_oneline = split[0] # must have at least one entry - help_text = '\n'.join(split[1:]) # may have other entries - else: - help_oneline = '' - help_text = '' + help_text, help_oneline = self.source.helptext(helpline) try: self.source.pmda.add_metric(self.mname, self.obj, help_oneline, help_text) @@ -565,7 +560,7 @@ class Source(object): self.path = path # pathname to .url or executable file self.url = None self.parse_error = False - self.parse_url_time = 0 # timestamp of config file when it was last parsed + self.parse_time = None # last time config file was parsed self.is_scripted = is_scripted self.pmda = thispmda # the shared pmda self.requests = None @@ -588,6 +583,16 @@ class Source(object): self.metrics_by_name = {} # name -> Metric self.metrics_by_num = {} # number (last component of pmid) -> Metric + def helptext(self, helpline): + if helpline: # it could be None! + unescaped = helpline.replace('\\\\', '\\').replace('\\n', '\n') + split = unescaped.split('\n') + help_oneline = split[0] # must have at least one entry + help_text = '\n'.join(split[1:]) # may have other entries + else: + help_oneline = '' + help_text = '' + return help_text, help_oneline def old_enough_for_refresh(self): '''But what is "old"? If it is empty (no metrics), then it @@ -685,6 +690,23 @@ class Source(object): self.pmda.debug("included_labels '%s'" % (included_labels)) if self.pmda.dbg else None self.pmda.debug("optional_labels '%s'" % (optional_labels)) if self.pmda.dbg else None if sp.name in self.metrics_by_name: + if ("openmetrics.%s.%s" % (self.name, sp.name)) not in self.pmda.all_metrics and self.name in self.pmda.re_add_list: + # re-add metric to namespace + if pcpline: + split = pcpline.split(" ") + fullname = "openmetrics.%s.%s" % (self.name, split[1]) + else: + fullname = "openmetrics.%s.%s" % (self.name, sp.name.replace(":", ".")) + help_oneline, help_text = self.helptext(helpline) + try: + obj = self.pmda.removed_metrics[fullname] + self.pmda.add_metric(fullname, obj, help_oneline, help_text) + self.pmda.debug("re-adding metric: %s to namespace" % fullname) if self.pmda.dbg else None + self.pmda.all_metrics[fullname] = obj + del self.pmda.removed_metrics[fullname] + self.pmda.set_need_refresh() + except Exception as e: + self.pmda.debug("Can't re-add metric: %s, see error: %s" % (fullname, e)) if self.pmda.dbg else None m = self.metrics_by_name[sp.name] assert self.metrics_by_num[m.metricnum] == m if m.singular: @@ -693,6 +715,7 @@ class Source(object): else: m.store_inst(naming_labels, sp.value) self.pmda.debug("naming_labels '%s'" % (naming_labels)) if self.pmda.dbg else None + # new metric case else: # check metric is not excluded by filters fullname = "openmetrics.%s.%s" % (self.name, sp.name) @@ -800,9 +823,9 @@ class Source(object): return num_metrics - def parse_url_config(self, filepath): + def parse_config(self, filepath): ''' - Parse a URL config file. The first line is always the URL. + Parse a configuration file. The first line is always the URL. Remaining lines are prefixed with a keyword. Supported keywords include '#' for a comment, 'HEADER:' to add to the header passed to the headers dict parameter to the get() call. Note the ':' are @@ -900,12 +923,12 @@ class Source(object): if not s[ST_MODE] & S_IXUSR: self.pmda.err("cannot execute script '%s'" % self.path) return - elif self.parse_url_time < s[ST_CTIME]: + elif self.parse_time is None or self.parse_time < s.st_mtime_ns: # (re)parse the URL from given file - self.parse_url_config(self.path) - self.parse_url_time = s[ST_CTIME] + self.parse_config(self.path) + self.parse_time = s.st_mtime_ns except Exception as e: - self.pmda.err("cannot read %s: %s" % (self.path, e)) + self.pmda.err("cannot stat %s: %s" % (self.path, e)) return # fetch the document @@ -1001,7 +1024,7 @@ class OpenMetricsPMDA(PMDA): # now everything else may take time self.pmda_name = pmda_name self.config_dir = os.path.normpath(config) - self.config_dir_ctime = None + self.config_dir_mtime = 0.0 self.timeout = timeout # a single central Session that all our sources can concurrently reuse @@ -1014,6 +1037,11 @@ class OpenMetricsPMDA(PMDA): reserved_cluster = self.cluster_table.intern_lookup_value("control") assert reserved_cluster == 0 self.source_by_cluster = {} + # all metrics added, to be used for removal + self.all_metrics = {} + # keep track of removed metrics, in case of re-addition + self.removed_metrics = {} + self.controls = {0:0} # compiled regex cache self.regex_cache = {} @@ -1106,26 +1134,53 @@ class OpenMetricsPMDA(PMDA): assert s == self.source_by_name[s.name] - def traverse(self, directory, ctime): + def traverse(self, directory, mtime): ''' Return list of files below dir, recursively ''' ret = [] - m = os.path.getctime(directory) - if ctime is None or m > ctime: - ctime = m + m = os.path.getmtime(directory) + if mtime is None or m > mtime: + mtime = m for path, subdirs, files in os.walk(directory): for f in files: if not f.startswith("."): fname = os.path.join(path, f) - m = os.path.getctime(fname) - if ctime is None or m > ctime: - ctime = m + m = os.path.getmtime(fname) + if mtime is None or m > mtime: + mtime = m ret.append(fname) + fname = os.path.join(path, f) + with open(fname, 'r') as name: + f_path = name.readline().strip() + if f_path.startswith("file:///"): + parsed = urlparse(f_path) + m = os.path.getmtime(parsed.path) + if mtime is None or m > mtime: + mtime = m for d in subdirs: - m, _ = self.traverse(os.path.join(path, d), ctime) - if ctime is None or m > ctime: - ctime = m - return ctime, ret + m, _ = self.traverse(os.path.join(path, d), mtime) + if mtime is None or m > mtime: + mtime = m + return mtime, ret + + def initialize_controls(self, cluster): + # initialize statistics + self.stats_fetch_calls[cluster] = 0 + self.stats_fetch_time[cluster] = 0 + self.stats_parse_time[cluster] = 0 + self.stats_status[cluster] = "unknown" + self.stats_status_code[cluster] = 0 + + self.controls[cluster] = 1 + + def delete_controls(self, cluster): + del self.stats_fetch_calls[cluster] + del self.stats_fetch_time[cluster] + del self.stats_parse_time[cluster] + del self.stats_status[cluster] + del self.stats_status_code[cluster] + + self.controls[cluster] = 0 def rescan_confdir(self): '''Scan the configuration directories for any new .url files @@ -1138,19 +1193,56 @@ class OpenMetricsPMDA(PMDA): ''' traverse_time = time.time() - dir_ctime, conf_filelist = self.traverse(self.config_dir, self.config_dir_ctime) + dir_mtime, conf_filelist = self.traverse(self.config_dir, self.config_dir_mtime) traverse_time = time.time() - traverse_time - if self.config_dir_ctime is None or self.config_dir_ctime < dir_ctime: - self.config_dir_ctime = dir_ctime + if self.config_dir_mtime < dir_mtime: + self.config_dir_mtime = dir_mtime else: # no new or changed conf files, don't rescan directory return self.log("Config change detected, traversed %d config entries in %.04fs, rescanning ..." % (len(conf_filelist), traverse_time)) nickname_regexp = self.lookup_regex(r"^[A-Za-z][A-Za-z0-9_.]*$") + self.re_add_list = [] + + # calculate config entry nicknames + nicknames = [] + for file in conf_filelist: + file_split = os.path.splitext(file) + name = file_split[0].replace(self.config_dir + "/", "").replace("/", ".") + nicknames.append(name) + + # check if config change adds a previously removed source + for key in self.removed_metrics: + split_name = key.split(".") + if split_name[1] in nicknames: + self.re_add_list.append(split_name[1]) + + # if source is not in config directory, remove the metric + for key, value in self.all_metrics.items(): + split_name = key.split(".") + if split_name[1] in nicknames: + continue + try: + remove_name = key + remove_obj = value + cluster = self.cluster_table.intern_lookup_value(split_name[1]) + if self.controls[cluster] == 1: + self.delete_controls(cluster) + self.remove_metric(remove_name, remove_obj) + self.removed_metrics[remove_name] = remove_obj + self.debug("removed metric name: %s" % remove_name) if self.dbg else None + self.set_need_refresh() + except Exception as e: + self.debug("can't remove metric: %s, see error: %s" % (key, e)) if self.dbg else None + + for key in self.removed_metrics: + if key in self.all_metrics: + del self.all_metrics[key] - # TODO: maybe nuke sources related to removed files save_cluster_table = False + cluster_for_refresh_names = [] + cluster_for_refresh = [] if sort_conf_list: # sorted for indom cluster consistency conf_filelist = sorted(conf_filelist) @@ -1177,6 +1269,13 @@ class OpenMetricsPMDA(PMDA): if name in self.source_by_name: # this source is already known self.assert_source_invariants(name=name) + s = self.source_by_name[name] + for key, value in self.source_by_cluster.items(): + if value == s: + cluster_for_refresh.append(key) + cluster_for_refresh_names.append(name) + if name in self.re_add_list: + self.initialize_controls(key) else: try: path = file @@ -1185,17 +1284,16 @@ class OpenMetricsPMDA(PMDA): self.source_by_name[source.name] = source self.source_by_cluster[source.cluster] = source - # initialize statistics - self.stats_fetch_calls[cluster] = 0 - self.stats_fetch_time[cluster] = 0 - self.stats_parse_time[cluster] = 0 - self.stats_status[cluster] = "unknown" - self.stats_status_code[cluster] = 0 + self.initialize_controls(cluster) save_cluster_table = True self.log("Found source %s cluster %d" % (name, cluster)) except Exception as e: self.err("Error allocating new cluster/source %s (%s)" % (name, e)) + + self.debug("refreshing cluster list: %s" % cluster_for_refresh_names) if self.dbg else None + self.refresh_some_clusters_for_fetch(cluster_for_refresh) + if save_cluster_table: self.cluster_table.save() self.set_notify_change()