#
# PCP JMX Connector PMDA
#
# Copyright (C) 2016 Marko Myllynen <myllynen@redhat.com>
#
# 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.
#

use strict;
use warnings;
use PCP::PMDA;
use IPC::Open3;
use threads;
use threads::shared;
use Scalar::Util qw(looks_like_number);

# Note that root cannot attach to other users' JVMs due to Java limitations.
# For details, see https://bugzilla.redhat.com/show_bug.cgi?id=1311638

our $os_user        = "pcp";
our $pmda_id        = 498;
our $pmda_name      = "jmx";
our $data_dir       = "/var/lib/pcp/pmdas/jmx";

# Use "java -DPCPJMXConnector.level=<LEVEL> ..." to set logging level
# Level is one of <FINEST|FINER|FINE|CONFIG|INFO|WARNING|SEVERE>
our $pcpjmx_config  = "$data_dir/pcpjmxconnector.properties";
our $pcpjmx_classp  = "$data_dir:/etc/alternatives/java_sdk/lib/tools.jar";
# Hint: could also use cat(1) with a previously created/customized CSV
# file which allows one additional filtering method and faster startup
our $pcpjmx_init    = "java -cp $pcpjmx_classp PCPJMXConnector --config $pcpjmx_config --init";
our $pcpjmx_linger  = "java -cp $pcpjmx_classp PCPJMXConnector --config $pcpjmx_config --linger";
our $pcpjmx_logid   = ": PCPJMXConnector";

# Static mappings for standard JVM attributes, PCP naming
# https://docs.oracle.com/javase/8/docs/api/java/lang/management/PlatformManagedObject.html
#
# Semantics - defaults to PM_SEM_INSTANT
our %semantics = ();
# Encoding - non-bools/strings default to pmda_units(0,0,1,0,0,PM_COUNT_ONE)
our %encoding = ();

# Configuration files for overriding the above settings
for my $file (pmda_config('PCP_PMDAS_DIR') . "/$pmda_name/$pmda_name.conf", "./$pmda_name.conf") {
	eval `cat $file` unless ! -f $file;
}

# PMDA Install integration
if (defined($ARGV[0]) && ($ARGV[0] eq '-c' || $ARGV[0] eq '--config')) {
	print("os_user=$os_user\n");
	exit(0);
}

# Detect PMDA init mode
my $pmda_init = 0;
if (defined ($ENV{'PCP_PERL_DOMAIN'}) || defined($ENV{'PCP_PERL_PMNS'})) {
	$pmda_init = 1;
}

my $pmda;
my $jmx_indom = 0;
my $lock :shared;
my %metrics :shared = ();

# Read a set of data from the connector
sub jmx_read_connector {
	my ($cmd) = @_;
	my $input = "";
	my $input_ok = 1;
	my $pid = open3(<&CHLD_IN>, \*CHLD_OUT, \*CHLD_ERR, "sudo -u $os_user $cmd");
	while(<CHLD_OUT>) {
		# Drop comments, grab the rest
		$input .= $_ unless $_ =~ /^#/;
	}
	while(<CHLD_ERR>) {
		if ($_ =~ /SEVERE/ || $_ =~ /WARNING/) {
			# Fatal, but capture all messages
			$input_ok = 0;
			$pmda->err($_);
		} else {
			$pmda->log($_);
		}
	}
	waitpid($pid, 0);
	my $rc = $?;
	$rc = $rc >> 8 unless $rc eq -1;
	if ($rc || !$input_ok) {
		$pmda->err("Initialization failure using command '$cmd' (code $rc).");
		exit(1);
	}
	return $input;
}

# Get initial data to generate metric descriptors
sub jmx_get_init_metrics {
	my %data = ();
	my $input = jmx_read_connector($pcpjmx_init);
	%data = jmx_format_results($input);
	%data = jmx_read_type_info(%data);
	jmx_prepara_pmns_conv(%data);
	return %data;
}

# Metric name conversion mapping as per PCP PMNS rules
my %pmns_conv = ();
sub jmx_prepara_pmns_conv {
	my (%data) = @_;
	my %metrics = ();
	foreach my $key (keys %data) {
		$metrics{$data{$key}->[1]} = 1;
	}
	foreach my $node (keys %metrics) {
		my $parent = substr($node, 0, rindex($node, "."));
		if (defined($metrics{$parent})) {
			# Non-leaf with value
			$pmns_conv{$parent} = $parent . "._leaf";
		}
	}
}

# Convert JMX attribute name to PCP metric name
sub jmx_convert_name {
	my ($metric) = @_;
	$metric = lc($metric);

	$metric =~ s/\./_/g;
	$metric =~ s/ - /./g;

	$metric =~ s/[\/,:\[\] -]/_/g;
	$metric =~ s/[^a-zA-Z0-9_.]//g;

	$metric = $pmns_conv{$metric} if defined $pmns_conv{$metric};

	return $metric;
}

# Put the received input into a hash
sub jmx_format_results {
	my ($input) = @_;
	my $item = 0;
	my %res = ();
	foreach my $line (split(/\n/, $input)) {
		my ($ts, $id, $metric, $value, $type) = split(/;/, $line);

		$metric = jmx_convert_name($metric);

		$res{$item}->[0] = $id;
		$res{$item}->[1] = $metric;
		$res{$item}->[2] = $value;
		$res{$item}->[3] = $type;
		$item++;
	}
	return %res;
}

# Read and construct metric descriptions
sub jmx_read_type_info {
	my (%data) = @_;
	my $type;
	foreach my $key (keys %data) {
		# Data type
		if ($data{$key}->[3] eq "boolean")		{ $type = PM_TYPE_STRING; }
		if ($data{$key}->[3] eq "int")			{ $type = PM_TYPE_32; }
		if ($data{$key}->[3] eq "long")			{ $type = PM_TYPE_64; }
		if ($data{$key}->[3] eq "float")		{ $type = PM_TYPE_FLOAT; }
		if ($data{$key}->[3] eq "double")		{ $type = PM_TYPE_DOUBLE; }
		if ($data{$key}->[3] eq "java.lang.String")	{ $type = PM_TYPE_STRING; }

		# Semantics
		my $fullname = $pmda_name . '.' . $data{$key}->[1];
		if (defined($semantics{$fullname})) {
			$data{$key}->[4] = $semantics{$fullname};
		} else {
			$data{$key}->[4] = PM_SEM_INSTANT;
		}

		# Encoding
		if ($type eq PM_TYPE_STRING) {
			$data{$key}->[5] = pmda_units(0,0,0,0,0,0);
		} elsif (defined($encoding{$fullname})) {
			$data{$key}->[5] = $encoding{$fullname};
		} else {
			$data{$key}->[5] = pmda_units(0,0,1,0,0,PM_COUNT_ONE);
		}

		# JMX to PCP
		$data{$key}->[3] = $type;
	}
	return %data;
}

# Pipe data from the JMX connector
sub jmx_pipe_reader {
	while (1) {
		sleep(5); # Allow PMDA to settle down
		$pmda->log("Starting PCP JMX Connector using command:");
		$pmda->log($pcpjmx_linger);
		my $pid = open3(<&CHLD_IN>, \*CHLD_OUT, \*CHLD_OUT, $pcpjmx_linger);
		while(<CHLD_OUT>) {
			chomp($_);
			jmx_line_reader(0, $_);
		}
		waitpid($pid, 0);
		my $rc = $?;
		$rc = $rc >> 8 unless $rc eq -1;
		if ($rc) {
			$pmda->err("Command '$pcpjmx_linger' failed (code $rc), restarting.");
		}
	}
}

my %interim = ();
sub jmx_line_reader {
	my (undef, $line) = @_;
	if ($line =~ /$pcpjmx_logid/ || $line =~ /^\s/) {
		if ($line =~ /SEVERE/ || $line =~ /WARNING/) {
			# Serious, discard data so far
			%interim = ();
			$pmda->err($line);
		} else {
			$pmda->log($line);
		}
		return;
	}
	if ($line =~ /^# BOD/) {
		%interim = ();
		return;
	}
	if ($line =~ /^# EOD/) {
		lock($lock);
		#%metrics = %interim;
		%metrics = ();
		foreach my $key (keys %interim) {
			$metrics{$key} = &share({});
			$metrics{$key} = shared_clone($interim{$key});
		}
		$pmda->replace_indom(0, \%interim);
		return;
	}

	return if $line =~ /^#/ or $line =~ /^$/;

	my ($ts, $id, $metric, $value, $type) = split(/;/, $line);

	$metric = jmx_convert_name($metric);

	my $fullname = $pmda_name . '.' . $metric;
	$interim{$id}->{$fullname} = $value;
}

sub jmx_fetch_callback {
	my ($cluster, $item, $inst) = @_;

	my $pmid_name = pmda_pmid_name($cluster, $item);

	lock($lock);

	$inst = pmda_inst_name($jmx_indom, $inst);

	return (PM_ERR_PMID, 0)		unless defined($pmid_name);
	return (PM_ERR_INST, 0)		unless defined($metrics{$inst});

	my $value = $metrics{$inst}->{$pmid_name};

	return (PM_ERR_APPVERSION, 0)	unless defined($value);
	# MBeans may signal never occurred peak usage as -1
	return (0, 1)			if (looks_like_number($value) and $value < 0);
	# MBeans may give NaN for uninitialized counters
	return (0, 1)			if ($value eq "NaN");

	return ($value, 1);
}

$pmda = PCP::PMDA->new($pmda_name, $pmda_id);
# Connect early to prevent possible timeouts when
# fetching initial metrics and providing them to PMCD.
$pmda->connect_pmcd();

my $item = 0;
my $cluster = 0;
my %data = jmx_get_init_metrics() unless defined($ENV{'PCP_PERL_DOMAIN'});
foreach my $key (keys %data) {
	my $metric = $data{$key}->[1];
	$metric = $pmns_conv{$metric} if defined $pmns_conv{$metric};
	my $fullname = $pmda_name . '.' . $metric;
	$pmda->add_metric(pmda_pmid($cluster, $item),
			$data{$key}->[3], $jmx_indom, $data{$key}->[4],
			$data{$key}->[5], $fullname, '', '');
	# A cluster space can hold at most 1024 metrics.
	# Since there are thousands of MBeans in certain
	# apps, we just split metrics into new clusters
	# in 1024 metric chunks if and when needed.
	if (++$item eq 1023) {
		$cluster++;
		$item = 0;
	}
}

$pmda->add_indom($jmx_indom, {}, '', '');
$pmda->set_fetch_callback(\&jmx_fetch_callback);
$pmda->set_user($os_user);

if (!$pmda_init) {
	threads->create(\&jmx_pipe_reader)->detach();
}

$pmda->run();
