Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/rrd-capability-coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: RRDtool capability coverage

on:
pull_request:
paths:
- 'lib/rrd.php'
- 'tests/Unit/Core/Rrd/RrdNativeUpdateCapabilitiesTest.php'
- 'tests/Unit/Core/Rrd/RrdVersionCheckReuseTest.php'
- 'tests/fixtures/Core/Rrd/**'
- 'tests/Helpers/RequestCoverage.php'
- 'tests/tools/check_changed_php_coverage.py'
- 'tests/tools/test_check_changed_php_coverage.py'
- 'phpunit-rrd-capabilities.xml'
- '.github/workflows/rrd-capability-coverage.yml'

permissions:
contents: read

jobs:
coverage:
name: Changed PHP lines >= 80%
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-php-composer
with:
php-version: '8.3'
coverage: xdebug
extensions: curl, dom, mbstring, xml
ini-values: memory_limit=2G
dependency-mode: locked-maintenance-pr-coverage
cache-dependency-path: tests/composer.lock
- name: Install locked test dependencies
run: composer install --working-dir=tests --prefer-dist --no-progress --no-interaction
- name: Validate the gate and include every changed production PHP file
env:
BASE_COMMIT: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
python3 tests/tools/test_check_changed_php_coverage.py
python3 tests/tools/check_changed_php_coverage.py --base "$BASE_COMMIT" \
--prepare-config phpunit-rrd-capabilities.xml --output-config phpunit-changed.xml
- name: Exercise native update and graph capability contracts
working-directory: tests
env:
XDEBUG_MODE: coverage
CACTI_PR_RAW_COVERAGE: ${{ runner.temp }}/request-coverage
run: |
set -euo pipefail
mkdir -p "$CACTI_PR_RAW_COVERAGE"
vendor/bin/pest --configuration=../phpunit-changed.xml --coverage-clover=../coverage-changed.xml
- name: Require at least 80 percent of changed executable PHP lines
env:
BASE_COMMIT: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
python3 tests/tools/check_changed_php_coverage.py --base "$BASE_COMMIT" \
--report coverage-changed.xml --request-reports "${{ runner.temp }}/request-coverage" \
--minimum 80 --json-output coverage-changed.json
- name: Preserve coverage evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: changed-php-coverage
path: |
coverage-changed.xml
coverage-changed.json
${{ runner.temp }}/request-coverage
if-no-files-found: warn
24 changes: 13 additions & 11 deletions lib/rrd.php
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,8 @@ function rrdtool_function_create($local_data_id, $show_source, $rrdtool_pipe = f
*/
function rrdtool_function_update($update_cache_array, $rrdtool_pipe = false, $unused_data_source_names_by_id = array()) {
/* lets count the number of rrd files processed */
$rrds_processed = 0;
$rrds_processed = 0;
$supports_skip_past_updates = null;

foreach ($update_cache_array as $rrd_path => $rrd_fields) {
$create_rrd_file = false;
Expand Down Expand Up @@ -1125,7 +1126,7 @@ function rrdtool_function_update($update_cache_array, $rrdtool_pipe = false, $un
}
}

if (cacti_version_compare(get_rrdtool_version(), '1.5', '>=')) {
if (($supports_skip_past_updates ??= cacti_version_compare(get_rrdtool_version(), '1.5', '>='))) {
$update_options = '--skip-past-updates';
} else {
$update_options = '';
Expand Down Expand Up @@ -1392,11 +1393,12 @@ function rrd_function_process_graph_options($graph_start, $graph_end, &$graph, &
include($config['include_path'] . '/global_arrays.php');

/* define some variables */
$scale = '';
$rigid = '';
$unit_value = '';
$version = get_rrdtool_version();
$unit_exponent_value = '';
$scale = '';
$rigid = '';
$unit_value = '';
$version = get_rrdtool_version();
$supports_graph_formatters = null;
$unit_exponent_value = '';

if ($graph['auto_scale'] == 'on') {
switch ($graph['auto_scale_opts']) {
Expand Down Expand Up @@ -1626,31 +1628,31 @@ function rrd_function_process_graph_options($graph_start, $graph_end, &$graph, &

break;
case 'legend_position':
if (cacti_version_compare($version, '1.4', '>=')) {
if (($supports_graph_formatters ??= cacti_version_compare($version, '1.4', '>='))) {
if (!empty($value)) {
$graph_opts .= '--legend-position ' . cacti_escapeshellarg($value) . RRD_NL;
}
}

break;
case 'legend_direction':
if (cacti_version_compare($version, '1.4', '>=')) {
if (($supports_graph_formatters ??= cacti_version_compare($version, '1.4', '>='))) {
if (!empty($value)) {
$graph_opts .= '--legend-direction ' . cacti_escapeshellarg($value) . RRD_NL;
}
}

break;
case 'left_axis_formatter':
if (cacti_version_compare($version, '1.4', '>=')) {
if (($supports_graph_formatters ??= cacti_version_compare($version, '1.4', '>='))) {
if (!empty($value)) {
$graph_opts .= '--left-axis-formatter ' . cacti_escapeshellarg($value) . RRD_NL;
}
}

break;
case 'right_axis_formatter':
if (cacti_version_compare($version, '1.4', '>=')) {
if (($supports_graph_formatters ??= cacti_version_compare($version, '1.4', '>='))) {
if (!empty($value)) {
$graph_opts .= '--right-axis-formatter ' . cacti_escapeshellarg($value) . RRD_NL;
}
Expand Down
9 changes: 9 additions & 0 deletions phpunit-rrd-capabilities.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/bootstrap-unit.php" beStrictAboutOutputDuringTests="false">
<testsuites>
<testsuite name="RrdCapabilities">
<file>tests/Unit/Core/Rrd/RrdNativeUpdateCapabilitiesTest.php</file>
<file>tests/Unit/Core/Rrd/RrdVersionCheckReuseTest.php</file>
</testsuite>
</testsuites>
</phpunit>
47 changes: 47 additions & 0 deletions tests/Helpers/RequestCoverage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
// Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.

// Loaded only by the disposable E2E image when coverage is explicitly enabled.
$requestCoverageDirectory = getenv('CACTI_PR_RAW_COVERAGE');

if ($requestCoverageDirectory !== false && $requestCoverageDirectory !== '') {
if (!function_exists('xdebug_start_code_coverage')) {
throw new RuntimeException('Request coverage requires Xdebug.');
}
xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE);
register_shutdown_function(static function () use ($requestCoverageDirectory) : void {
$data = xdebug_get_code_coverage();

foreach ($GLOBALS['cacti_fixture_source_map'] ?? [] as $temporary => $original) {
foreach ($data[$temporary] ?? [] as $line => $hits) {
$data[$original][$line] = max($hits, $data[$original][$line] ?? -2);
}
unset($data[$temporary]);
}
$root = realpath(dirname(__DIR__, 2));
$hashes = [];
$htmlSpans = [];

foreach ($data as $file => $lines) {
if (!str_starts_with($file, $root . '/') || str_contains($file, '/vendor/')) {
unset($data[$file]);

continue;
}
$hashes[$file] = hash_file('sha256', $file);
$htmlSpans[$file] = [];

// Zend records a literal HTML output opcode at the end of its block.
foreach (token_get_all(file_get_contents($file)) as $token) {
if (is_array($token) && $token[0] === T_INLINE_HTML) {
$htmlSpans[$file][] = [$token[2], $token[2] + substr_count($token[1], "\n")];
}
}
}
$path = $requestCoverageDirectory . '/' . getmypid() . '-' . bin2hex(random_bytes(8)) . '.json';

if (file_put_contents($path, json_encode(['root' => $root, 'coverage' => $data, 'hashes' => $hashes, 'html_spans' => $htmlSpans], JSON_THROW_ON_ERROR)) === false) {
throw new RuntimeException('Could not preserve request coverage.');
}
});
}
3 changes: 2 additions & 1 deletion tests/Helpers/SourceSlice.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ function cacti_fixture_source_function(string $file, string $function, string $n
}
$line = substr_count(substr($source, 0, $match[0][1]), "\n") + 1;
$temporary = tempnam(sys_get_temp_dir(), 'cacti-source-slice-');
file_put_contents($temporary, '<?php namespace ' . $namespace . ';' . str_repeat("\n", $line - 1) . $match[0][0]);
$header = '<?php ' . ($namespace === '' ? '' : 'namespace ' . $namespace . '; use \\RuntimeException; use \\Throwable;');
file_put_contents($temporary, $header . str_repeat("\n", $line - 1) . $match[0][0]);
$GLOBALS['cacti_fixture_source_map'][$temporary] = realpath($file);

try {
Expand Down
10 changes: 10 additions & 0 deletions tests/Unit/Core/Rrd/RrdNativeUpdateCapabilitiesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
// Copyright (C) 2026 The Cacti Group. GPL-2.0-or-later.

test('native update batches retain timestamp order and version options', function ($version) {
$fixture = dirname(__DIR__, 3) . '/fixtures/Core/Rrd/NativeUpdateCapabilities.php';
exec(escapeshellarg(PHP_BINARY) . ' -d display_errors=stderr ' . escapeshellarg($fixture) . ' ' . escapeshellarg($version), $output, $status);
expect($status)->toBe(0);
$result = json_decode(implode("\n", $output), true, 512, JSON_THROW_ON_ERROR);
expect($result['count'])->toBe(6)->and($result['empty'])->toBe(0)->and($result['commands'])->toHaveCount(6);
})->with(['1.3.0', '1.4.0', '1.5.0', '1.8.0']);
17 changes: 17 additions & 0 deletions tests/Unit/Core/Rrd/RrdVersionCheckReuseTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php
// Copyright (C) 2026 The Cacti Group. GPL-2.0-or-later.
test('RRD update capability checks are reused within batches and refreshed between calls', function () {
$fixture = dirname(__DIR__, 3) . '/fixtures/Core/Rrd/UpdateCapabilities.php';
exec(escapeshellarg(PHP_BINARY) . ' -d display_errors=stderr ' . escapeshellarg($fixture), $output, $status);
expect($status)->toBe(0);
$result = json_decode(implode("\n", $output), true, 512, JSON_THROW_ON_ERROR);

foreach ($result['cases'] as $case) {
expect($case['count'])->toBe(6)->and($case['comparisons'])->toBe(1);

foreach ($case['commands'] as $command) {
expect(str_contains($command, '--skip-past-updates'))->toBe(version_compare($case['version'], '1.5.0', '>='));
}
}
expect($result['empty_comparisons'])->toBe(0);
});
75 changes: 75 additions & 0 deletions tests/fixtures/Core/Rrd/NativeUpdateCapabilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php
// Copyright (C) 2026 The Cacti Group. GPL-2.0-or-later.
$root = dirname(__DIR__, 4);
require $root . '/tests/Helpers/RequestCoverage.php';
require $root . '/include/global_constants.php';
require $root . '/lib/functions.php';

$directory = sys_get_temp_dir() . '/cacti-native-update-' . bin2hex(random_bytes(8));
mkdir($directory, 0700);
$binary = $directory . '/rrdtool';
$log = $directory . '/commands';
$config = ['is_web' => false, 'cacti_server_os' => 'unix', 'config_options_array' => ['rrdtool_version' => $argv[1] ?? '1.4.0', 'storage_location' => 0, 'path_rrdtool' => $binary, 'path_rrdtool_default_font' => '', 'i18n_language_support' => '0']];
require $root . '/include/global_languages.php';
require $root . '/lib/auth.php';
require $root . '/lib/plugins.php';
require $root . '/lib/html.php';
define('IN_CACTI_INSTALL', true);
$config += ['poller_id' => 1, 'connection' => false, 'url_path' => '/'];
require $root . '/lib/rrd.php';
$pipe = false;

try {
// Exercise Cacti's native code with an external process that records commands.
file_put_contents($binary, '#!' . PHP_BINARY . "\n<?php\nwhile ((\$line = fgets(STDIN)) !== false) { file_put_contents(" . var_export($log, true) . ", \$line, FILE_APPEND); }\n");
chmod($binary, 0700);
$pipe = rrd_init(false);
$updates = [];

foreach (['first', 'second'] as $name) {
$path = $directory . '/' . $name . '.rrd';
touch($path);
$updates[$path] = ['local_data_id' => 0, 'data_template_id' => 0, 'times' => [1700000180 => ['value' => 44], 1700000060 => ['value' => 42], 1700000120 => ['value' => 43]]];
}
$count = rrdtool_function_update($updates, $pipe);
$empty = rrdtool_function_update([], $pipe);
rrd_close($pipe);
$pipe = false;
$commands = file($log, FILE_IGNORE_NEW_LINES);

if ($count !== 6 || $empty !== 0 || count($commands) !== 6) {
throw new RuntimeException('Native update did not send the complete batch.');
}
foreach ($commands as $index => $command) {
$sample = [1700000060, 1700000120, 1700000180][$index % 3] . ':' . [42, 43, 44][$index % 3];
if (!str_contains($command, $sample) || str_contains($command, '--skip-past-updates') !== version_compare(get_rrdtool_version(), '1.5', '>=')) {
throw new RuntimeException('Native update changed timestamp order or capability options.');
}
}
$config['include_path'] = $root . '/include';
$config['base_path'] = $root;
$config['config_options_array'] += ['i18n_language_support' => '0', 'auth_method' => 1, 'font_method' => 0, 'rrdtool_watermark' => '', 'default_date_format' => GD_Y_MO_D, 'default_datechar' => 1, 'graph_watermark' => '', 'graph_watermark_rrd' => '', 'selected_theme' => 'modern'];
$_SESSION = ['sess_user_config_array' => ['default_date_format' => GD_Y_MO_D, 'default_datechar' => 1, 'custom_fonts' => '']];
foreach (['title', 'axis', 'legend', 'unit', 'watermark'] as $type) {
$config['config_options_array'][$type . '_font'] = 'DejaVu Sans';
$config['config_options_array'][$type . '_size'] = 10;
}
require $root . '/include/global_arrays.php';
$graph = ['auto_scale' => 'on', 'auto_scale_opts' => '1', 'lower_limit' => '0', 'upper_limit' => '100', 'auto_scale_log' => '', 'scale_log_units' => '', 'auto_scale_rigid' => '', 'unit_value' => '', 'unit_exponent_value' => '', 'height' => '120', 'width' => '500', 'image_format_id' => 3, 'title_cache' => 'Capabilities', 'vertical_label' => 'Values', 'legend_position' => 'south', 'legend_direction' => 'topdown', 'left_axis_formatter' => 'numeric', 'right_axis_formatter' => 'numeric'];
$data = ['graph_start' => 1700000000, 'graph_end' => 1700003600, 'graph_theme' => 'modern'];
$graphCommand = rrd_function_process_graph_options(1700000000, 1700003600, $graph, $data);
foreach (['legend-position', 'legend-direction', 'left-axis-formatter', 'right-axis-formatter'] as $flag) {
if (str_contains($graphCommand, '--' . $flag) !== version_compare(get_rrdtool_version(), '1.4', '>=')) {
throw new RuntimeException('Native graph formatter capability changed.');
}
}
echo json_encode(['count' => $count, 'empty' => $empty, 'commands' => $commands], JSON_THROW_ON_ERROR), "\n";
} finally {
if ($pipe !== false) {
rrd_close($pipe);
}
foreach (glob($directory . '/*') as $path) {
unlink($path);
}
rmdir($directory);
}
58 changes: 58 additions & 0 deletions tests/fixtures/Core/Rrd/UpdateCapabilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php
// Copyright (C) 2026 The Cacti Group. GPL-2.0-or-later.
// Isolated production update body with instrumented process boundaries.
$root = dirname(__DIR__, 4);
require $root . '/include/global_constants.php';
require $root . '/lib/functions.php';
$state = ['version' => '1.3.0', 'comparisons' => 0, 'commands' => []];
function capability_fixture_version() {
return $GLOBALS['state']['version'];
}
function capability_fixture_compare($left, $right, $operator) {
$GLOBALS['state']['comparisons']++;

return cacti_version_compare($left, $right, $operator);
}
function capability_fixture_exists($path) {
return true;
}
function capability_fixture_config($name) {
return 0;
}
function capability_fixture_execute($command, ...$args) {
$GLOBALS['state']['commands'][] = $command;

return '';
}
$source = file_get_contents($root . '/lib/rrd.php');
$start = strpos($source, 'function rrdtool_function_update(');
$end = strpos($source, "\nfunction ", $start + 1);
$function = substr($source, $start, $end - $start);
$map = ['rrdtool_function_update' => 'capability_fixture_update', 'get_rrdtool_version' => 'capability_fixture_version', 'cacti_version_compare' => 'capability_fixture_compare', 'file_exists' => 'capability_fixture_exists', 'read_config_option' => 'capability_fixture_config', 'rrdtool_execute' => 'capability_fixture_execute'];
$compiled = '';

foreach (token_get_all('<?php ' . $function) as $token) {
if (is_array($token)) {
if ($token[0] !== T_OPEN_TAG) {
$compiled .= $token[0] === T_STRING ? ($map[$token[1]] ?? $token[1]) : $token[1];
}
} else {
$compiled .= $token;
}
}
eval($compiled);
$updates = [];

foreach (['/tmp/capability-first.rrd', '/tmp/capability-second.rrd'] as $path) {
$updates[$path] = ['local_data_id' => 0, 'data_template_id' => 0, 'times' => [1700000060 => ['value' => 42], 1700000120 => ['value' => 43], 1700000180 => ['value' => 44]]];
}
$results = [];

foreach (['1.3.0', '1.4.0', '1.5.0', '1.8.0', '1.3.0'] as $version) {
$state = ['version' => $version, 'comparisons' => 0, 'commands' => []];
$count = capability_fixture_update($updates);
$results[] = ['version' => $version, 'count' => $count, 'comparisons' => $state['comparisons'], 'commands' => $state['commands']];
}
$state['comparisons'] = 0;
capability_fixture_update([]);
echo json_encode(['cases' => $results, 'empty_comparisons' => $state['comparisons']], JSON_THROW_ON_ERROR), "\n";
Loading
Loading