Skip to content
Open
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ Only changes are recorded. If a job template has Show Changes enabled and the mo

The search box searches the change data itself, so you can ask which automated changes touched `/etc/sudoers`.

### Reports

Both kinds of data can be saved as a report, which is shared with other users and mailed on a schedule.

A facts report picks the facts to show as its columns and selects hosts by comparing their fact values. A changes
report lists the changes the way the Changes page does, narrowed by the same filters: host, job template, playbook,
role, module, job type, inventory, project and a search of the change data. It also takes a window in hours, so a
scheduled report covers the changes of the last night rather than everything ever recorded.

## Authentication

Ledger ignores data from servers it has not been told to trust, so a new server sends nothing useful until you approve it.
Expand Down
1 change: 1 addition & 0 deletions files/web.conf
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ server {
rewrite ^/reports/delete/([0-9]+)?$ /reports.php?action=delete&report=$1 break;
rewrite ^/reports/edit/([0-9]+)?$ /reports.php?action=edit&report=$1 break;
rewrite ^/reports/new/?$ /reports.php?action=new break;
rewrite ^/reports/new/([a-z]+)/?$ /reports.php?action=new&type=$1 break;
rewrite ^/reports/view/([0-9]+)?$ /reports.php?action=view&report=$1 break;

rewrite ^/reports/perms/([0-9]+)/user/remove/([0-9]+)?$ /reports.php?action=removeuserperm&report=$1&user=$2 break;
Expand Down
68 changes: 64 additions & 4 deletions src/includes/classes/Report.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Report {
var $sortc = 0;
var $sortd = 'asc';
var $role = '';
var $type = 'facts';

function __construct($id = 0) {
if ($id) {
Expand Down Expand Up @@ -44,6 +45,14 @@ function set_created($created) {
$this->created = intval($created);
}

function set_type($type) {
if ($type == 'changes') {
$this->type = 'changes';
} else {
$this->type = 'facts';
}
}

function set_columns($columns) {
$this->columns = $columns;
}
Expand Down Expand Up @@ -75,6 +84,10 @@ function pop_class($u) {
$this->sortc = $u['sortc'];
$this->sortd = $u['sortd'];
$this->role = (isset($u['role']) ? $u['role'] : 'view');
$this->set_type(isset($u['type']) ? $u['type'] : 'facts');
if ($this->type == 'changes') {
$this->columns = changes_report_columns();
}
}
}

Expand All @@ -101,6 +114,53 @@ function add_filter($fact, $compare, $value) {
$this->save();
}

/*
Changes reports filter the changes table rather than the facts table, so their filters
are a field of the changes view and the value to match, in the same shape the changes
page builds from its own filter menus. One filter per field, as on that page.
*/
function add_changes_filter($field, $value) {
$fields = changes_report_filter_fields();

if (!isset($fields[$field])) {
return false;
}

if (in_array($field, changes_report_numeric_filter_fields())) {
$value = intval($value);
if ($value < 1) {
return false;
}
} else {
$value = $this->clean_filter($value);
if ($value === '') {
return false;
}
}

$filters = $this->strip_changes_filter($field);
$filters[] = array('field' => $field, 'value' => $value);
$this->filters = $filters;
$this->save();

return true;
}

function remove_changes_filter($field) {
$this->filters = $this->strip_changes_filter($field);
$this->save();
}

function strip_changes_filter($field) {
$kept = array();
foreach ($this->filters as $f) {
if (!isset($f['field']) || $f['field'] != $field) {
$kept[] = $f;
}
}
return $kept;
}

function remove_filter($i) {
unset($this->filters[$i]);
$this->save();
Expand Down Expand Up @@ -221,11 +281,11 @@ function delete() {

function save() {
if ($this->id) {
db_execute_prepare('UPDATE `reports` SET `owner` = ?, `name` = ?, `created` = ?, `filters` = ?, `columns` = ?, `sortc` = ?, `sortd` = ? WHERE `id` = ?',
array($this->owner, $this->name, $this->created, base64_encode(serialize($this->filters)), base64_encode(serialize($this->columns)), $this->sortc, $this->sortd, $this->id));
db_execute_prepare('UPDATE `reports` SET `owner` = ?, `name` = ?, `created` = ?, `filters` = ?, `columns` = ?, `sortc` = ?, `sortd` = ?, `type` = ? WHERE `id` = ?',
array($this->owner, $this->name, $this->created, base64_encode(serialize($this->filters)), base64_encode(serialize($this->columns)), $this->sortc, $this->sortd, $this->type, $this->id));
} else {
$id = db_execute_prepare('INSERT INTO `reports` (`owner`, `name`, `created`, `filters`, `columns`, `sortc`, `sortd`) VALUES (?, ?, ?, ?, ?, ?, ?)',
array($this->owner, $this->name, $this->created, base64_encode(serialize($this->filters)),base64_encode(serialize($this->columns)), $this->sortc, $this->sortd));
$id = db_execute_prepare('INSERT INTO `reports` (`owner`, `name`, `created`, `filters`, `columns`, `sortc`, `sortd`, `type`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
array($this->owner, $this->name, $this->created, base64_encode(serialize($this->filters)),base64_encode(serialize($this->columns)), $this->sortc, $this->sortd, $this->type));
$this->id = $id;
db_execute_prepare('INSERT INTO `reports_perms` (`report`, `user`, `role`) VALUES (?, ?, ?)',
array($this->id, $this->owner, 'owner'));
Expand Down
6 changes: 5 additions & 1 deletion src/includes/db_upgrade.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

$new_db_version = 17;
$new_db_version = 18;

$db_version = read_setting('db_version', 1);

Expand Down Expand Up @@ -87,6 +87,10 @@
case 15:
case 16:
db_execute("ALTER TABLE `users` CHANGE `password` `password` varchar(256) NOT NULL AFTER `email`;");
case 17:
if (!db_column_exists('reports', 'type')) {
db_execute("ALTER TABLE `reports` ADD `type` varchar(16) NOT NULL DEFAULT 'facts' AFTER `name`");
}

}
} catch (Exception $e) {
Expand Down
7 changes: 4 additions & 3 deletions src/includes/ledger.sql
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ CREATE TABLE IF NOT EXISTS `reports` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`owner` int(11) NOT NULL,
`name` varchar(256) NOT NULL,
`type` varchar(16) NOT NULL DEFAULT 'facts',
`created` int(11) NOT NULL,
`filters` text NOT NULL,
`columns` text NOT NULL,
Expand All @@ -116,9 +117,9 @@ INSERT INTO `reports_perms` (`report`, `user`, `role`) VALUES
(1, 1, 'owner'),
(2, 1, 'owner');

INSERT INTO `reports` (`id`, `owner`, `name`, `created`, `filters`, `columns`, `sortc`, `sortd`) VALUES
(1, 1, 'Linux Servers', 1663558186, 'YToxOntpOjA7YTozOntzOjQ6ImZhY3QiO3M6MTQ6ImFuc2libGVfc3lzdGVtIjtzOjc6ImNvbXBhcmUiO3M6MjoiZXEiO3M6NToidmFsdWUiO3M6NToiTGludXgiO319', 'YTo3OntzOjg6Ikhvc3RuYW1lIjtzOjE2OiJhbnNpYmxlX2hvc3RuYW1lIjtzOjEwOiJJUCBBZGRyZXNzIjtzOjI4OiJhbnNpYmxlX2RlZmF1bHRfaXB2NC5hZGRyZXNzIjtzOjY6IkRpc3RybyI7czoyMDoiYW5zaWJsZV9kaXN0cmlidXRpb24iO3M6MTQ6IkRpc3RybyBWZXJzaW9uIjtzOjI4OiJhbnNpYmxlX2Rpc3RyaWJ1dGlvbl92ZXJzaW9uIjtzOjE0OiJQeXRob24gVmVyc2lvbiI7czoyMjoiYW5zaWJsZV9weXRob25fdmVyc2lvbiI7czo0OiJDUFVzIjtzOjIzOiJhbnNpYmxlX3Byb2Nlc3Nvcl92Y3B1cyI7czo2OiJNZW1vcnkiO3M6MTk6ImFuc2libGVfbWVtdG90YWxfbWIiO30=', 0, 'asc'),
(2, 1, 'Windows Servers', 1664418280, 'YToxOntpOjA7YTozOntzOjQ6ImZhY3QiO3M6MTc6ImFuc2libGVfb3NfZmFtaWx5IjtzOjc6ImNvbXBhcmUiO3M6MjoiZXEiO3M6NToidmFsdWUiO3M6NzoiV2luZG93cyI7fX0=', 'YTo2OntzOjg6Ikhvc3RuYW1lIjtzOjE2OiJhbnNpYmxlX2hvc3RuYW1lIjtzOjEwOiJJUCBBZGRyZXNzIjtzOjIyOiJhbnNpYmxlX2lwX2FkZHJlc3Nlcy4wIjtzOjEwOiJPUyBWZXJzaW9uIjtzOjIwOiJhbnNpYmxlX2Rpc3RyaWJ1dGlvbiI7czoxMDoiUG93ZXJzaGVsbCI7czoyNjoiYW5zaWJsZV9wb3dlcnNoZWxsX3ZlcnNpb24iO3M6NDoiQ1BVcyI7czoyMzoiYW5zaWJsZV9wcm9jZXNzb3JfdmNwdXMiO3M6NjoiTWVtb3J5IjtzOjE5OiJhbnNpYmxlX21lbXRvdGFsX21iIjt9', 0, 'asc');
INSERT INTO `reports` (`id`, `owner`, `name`, `type`, `created`, `filters`, `columns`, `sortc`, `sortd`) VALUES
(1, 1, 'Linux Servers', 'facts', 1663558186, 'YToxOntpOjA7YTozOntzOjQ6ImZhY3QiO3M6MTQ6ImFuc2libGVfc3lzdGVtIjtzOjc6ImNvbXBhcmUiO3M6MjoiZXEiO3M6NToidmFsdWUiO3M6NToiTGludXgiO319', 'YTo3OntzOjg6Ikhvc3RuYW1lIjtzOjE2OiJhbnNpYmxlX2hvc3RuYW1lIjtzOjEwOiJJUCBBZGRyZXNzIjtzOjI4OiJhbnNpYmxlX2RlZmF1bHRfaXB2NC5hZGRyZXNzIjtzOjY6IkRpc3RybyI7czoyMDoiYW5zaWJsZV9kaXN0cmlidXRpb24iO3M6MTQ6IkRpc3RybyBWZXJzaW9uIjtzOjI4OiJhbnNpYmxlX2Rpc3RyaWJ1dGlvbl92ZXJzaW9uIjtzOjE0OiJQeXRob24gVmVyc2lvbiI7czoyMjoiYW5zaWJsZV9weXRob25fdmVyc2lvbiI7czo0OiJDUFVzIjtzOjIzOiJhbnNpYmxlX3Byb2Nlc3Nvcl92Y3B1cyI7czo2OiJNZW1vcnkiO3M6MTk6ImFuc2libGVfbWVtdG90YWxfbWIiO30=', 0, 'asc'),
(2, 1, 'Windows Servers', 'facts', 1664418280, 'YToxOntpOjA7YTozOntzOjQ6ImZhY3QiO3M6MTc6ImFuc2libGVfb3NfZmFtaWx5IjtzOjc6ImNvbXBhcmUiO3M6MjoiZXEiO3M6NToidmFsdWUiO3M6NzoiV2luZG93cyI7fX0=', 'YTo2OntzOjg6Ikhvc3RuYW1lIjtzOjE2OiJhbnNpYmxlX2hvc3RuYW1lIjtzOjEwOiJJUCBBZGRyZXNzIjtzOjIyOiJhbnNpYmxlX2lwX2FkZHJlc3Nlcy4wIjtzOjEwOiJPUyBWZXJzaW9uIjtzOjIwOiJhbnNpYmxlX2Rpc3RyaWJ1dGlvbiI7czoxMDoiUG93ZXJzaGVsbCI7czoyNjoiYW5zaWJsZV9wb3dlcnNoZWxsX3ZlcnNpb24iO3M6NDoiQ1BVcyI7czoyMzoiYW5zaWJsZV9wcm9jZXNzb3JfdmNwdXMiO3M6NjoiTWVtb3J5IjtzOjE5OiJhbnNpYmxlX21lbXRvdGFsX21iIjt9', 0, 'asc');

CREATE TABLE IF NOT EXISTS `reports_schedules` (
`id` int(11) NOT NULL AUTO_INCREMENT,
Expand Down
220 changes: 219 additions & 1 deletion src/includes/misc.php
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,222 @@ function build_report ($id) {
}
}
return $data;
}
}

/*
The columns a changes report shows. Unlike a facts report, where the columns are the facts
the user picked, a changes report always shows the same fields the changes page lists, so the
report is that page's table with its filters saved and schedulable.
*/
function changes_report_columns() {
return array(
'Time' => 'time',
'Host' => 'hostname',
'Job Template' => 'template',
'Playbook' => 'playbook',
'Role' => 'role',
'Task' => 'task',
'Module' => 'task_action',
);
}

/*
The fields a changes report can be filtered on. These are the filter menus of the changes
page, plus a time window, which is what makes a scheduled report useful: "the changes of the
last 24 hours" rather than everything ever recorded.
*/
function changes_report_filter_fields() {
return array(
'host' => 'Host',
'template' => 'Job Template',
'playbook' => 'Playbook',
'role' => 'Role',
'module' => 'Module',
'type' => 'Job Type',
'inventory' => 'Inventory',
'project' => 'Project',
'search' => 'Search',
'hours' => 'Last Hours',
);
}

/*
The filter fields whose value is an id or a count rather than a name.
*/
function changes_report_numeric_filter_fields() {
return array('host', 'template', 'inventory', 'project', 'hours');
}

/*
Turns the filters of a changes report into a WHERE clause and its parameters. The comparisons
are the ones the changes page makes, with the values bound rather than interpolated.
*/
function build_changes_filter($filters) {
$where = array();
$p = array();

foreach ($filters as $f) {
if (!isset($f['field']) || !isset($f['value'])) {
continue;
}

switch ($f['field']) {
case 'host':
$where[] = '`changes`.`host` = ?';
$p[] = intval($f['value']);
break;
case 'template':
$where[] = '`jobs`.`job_template_id` = ?';
$p[] = intval($f['value']);
break;
case 'playbook':
$where[] = '`changes`.`playbook` = ?';
$p[] = $f['value'];
break;
case 'role':
$where[] = '`changes`.`role` = ?';
$p[] = $f['value'];
break;
case 'module':
$where[] = '`changes`.`task_action` = ?';
$p[] = $f['value'];
break;
case 'type':
$where[] = '`jobs`.`job_type` = ?';
$p[] = $f['value'];
break;
case 'inventory':
$where[] = '`jobs`.`inventory` LIKE ?';
$p[] = '%-' . intval($f['value']);
break;
case 'project':
$where[] = '`jobs`.`project` LIKE ?';
$p[] = '%-' . intval($f['value']);
break;
case 'search':
foreach (explode(' ', $f['value']) as $s) {
if ($s == '') {
continue;
}
$where[] = '(`changes`.`res` LIKE ? OR `changes`.`task_action` LIKE ? OR `changes`.`task` LIKE ? OR `changes`.`role` LIKE ? OR `changes`.`play` LIKE ?)';
for ($i = 0; $i < 5; $i++) {
$p[] = '%' . $s . '%';
}
}
break;
case 'hours':
$where[] = '`changes`.`time` >= ?';
$p[] = time() - (intval($f['value']) * 3600);
break;
}
}

if (empty($where)) {
return array('', $p);
}

return array('WHERE ' . implode(' AND ', $where), $p);
}

/*
Builds a changes report, returning the same shape as build_report: one row per change, each
holding the cell values of changes_report_columns() in order, so the report and email
templates render both kinds of report the same way.
*/
function build_changes_report($id, $limit = 1000) {
$id = intval($id);
$report = new Report($id);
$data = array();

if (!$report->id || $report->type != 'changes') {
return $data;
}

$w = build_changes_filter($report->filters);

$sql = "SELECT `changes`.`id`, `changes`.`time`, `changes`.`playbook`, `changes`.`role`, `changes`.`task`, `changes`.`task_action`,
`hosts`.`hostname`, `jobs`.`name` as `template`
FROM `changes`
LEFT JOIN `jobs` ON `jobs`.`job` = `changes`.`job`
LEFT JOIN `hosts` ON `hosts`.`id` = `changes`.`host`
" . $w[0] . "
ORDER BY `changes`.`time` DESC
LIMIT " . intval($limit);

$changes = db_fetch_assocs_prepare($sql, $w[1]);

foreach ($changes as $c) {
$data[$c['id']] = array(
date('m/d/Y H:i', $c['time']),
$c['hostname'],
$c['template'],
$c['playbook'],
$c['role'],
$c['task'],
$c['task_action'],
);
}

return $data;
}

/*
The values the filter menus of a changes report offer, built from the changes that were
actually recorded, the same way the changes page builds its own menus.
*/
function changes_report_filter_options() {
$hosts = array();
foreach (db_fetch_assocs('SELECT `id`, `hostname` FROM `hosts` ORDER BY `hostname`') as $h) {
$hosts[$h['id']] = $h['hostname'];
}

$templates = array();
foreach (db_fetch_assocs("SELECT DISTINCT `jobs`.`job_template_id`, `jobs`.`name` FROM `changes` LEFT JOIN `jobs` ON `jobs`.`job` = `changes`.`job` ORDER BY `jobs`.`name` ASC") as $t) {
if ($t['job_template_id']) {
$templates[$t['job_template_id']] = $t['name'];
}
}

$inventories = array();
foreach (db_fetch_assocs("SELECT DISTINCT `jobs`.`inventory` FROM `changes` LEFT JOIN `jobs` ON `jobs`.`job` = `changes`.`job` ORDER BY `jobs`.`inventory` ASC") as $i) {
$i = explode('-', $i['inventory']);
$id = array_pop($i);
if (intval($id)) {
$inventories[$id] = implode('-', $i);
}
}

$projects = array();
foreach (db_fetch_assocs("SELECT DISTINCT `jobs`.`project` FROM `changes` LEFT JOIN `jobs` ON `jobs`.`job` = `changes`.`job` ORDER BY `jobs`.`project` ASC") as $i) {
$i = explode('-', $i['project']);
$id = array_pop($i);
if (intval($id)) {
$projects[$id] = implode('-', $i);
}
}

return array(
'host' => $hosts,
'template' => $templates,
'playbook' => changes_report_option_list("SELECT DISTINCT `playbook` FROM `changes` ORDER BY `playbook` ASC", 'playbook'),
'role' => changes_report_option_list("SELECT DISTINCT `role` FROM `changes` ORDER BY `role` ASC", 'role'),
'module' => changes_report_option_list("SELECT DISTINCT `task_action` FROM `changes` ORDER BY `task_action` ASC", 'task_action'),
'type' => array('run' => 'Run Mode', 'check' => 'Check Mode'),
'inventory' => $inventories,
'project' => $projects,
);
}

/*
Reads a column of distinct values into an option list where the value and the label are the
same, which is how the changes table stores playbooks, roles and modules.
*/
function changes_report_option_list($sql, $column) {
$options = array();
foreach (db_fetch_assocs($sql) as $row) {
if ($row[$column] != '') {
$options[$row[$column]] = $row[$column];
}
}
return $options;
}
Loading