///|
/// Change policy layer: operational rules turned into explicit violations.
///
/// The audit answers "is this plan internally consistent?". A policy answers
/// "is this plan acceptable for us?" - how many changes may run at once, how
/// long a single change may last, how much slack a dependency needs, how many
/// high risk changes a day may carry, and whether changes may run inside a
/// freeze.
///
/// Nothing here mutates the plan or re-implements the audit: every violation is
/// derived from `audit(plan)` and from the exported concurrency and time
/// helpers, so a policy report always agrees with the audit it wraps.
///
/// Violations are produced in a fixed rule order and, inside a rule, in plan or
/// service order. Two runs over the same plan therefore render the same text.
///|
/// Thresholds and flags applied on top of an audit.
///
/// A limit of `0` means "unlimited", and a flag set to `false` disables the
/// rule it belongs to. `Policy::relaxed()` therefore reports nothing at all.
///
/// `max_change_minutes` measures each change on its own and never counts
/// freezes; the freeze rules are `forbid_freeze_conflicts` and, indirectly,
/// `require_service` only for changes.
pub(all) struct Policy {
max_concurrent : Int
max_change_minutes : Int
min_dependency_slack_minutes : Int
forbid_high_risk_overlap : Bool
max_high_risk_per_day : Int
forbid_freeze_conflicts : Bool
require_service : Bool
}
///|
/// The default policy: every numeric limit is off and an unnamed service is
/// accepted, but a change may not run inside a declared freeze.
///
/// `forbid_freeze_conflicts` is the only rule that is on, because a change
/// running inside a freeze is a contradiction rather than a tuning choice.
pub fn Policy::standard() -> Policy {
{
max_concurrent: 0,
max_change_minutes: 0,
min_dependency_slack_minutes: 0,
forbid_high_risk_overlap: false,
max_high_risk_per_day: 0,
forbid_freeze_conflicts: true,
require_service: false,
}
}
///|
/// A tight policy for a fragile service: two changes at a time, no change
/// longer than two hours, thirty minutes of slack after every dependency, high
/// risk changes that never overlap, at most two high risk changes a day, no
/// change inside a freeze and no change without a named service.
pub fn Policy::strict() -> Policy {
{
max_concurrent: 2,
max_change_minutes: 120,
min_dependency_slack_minutes: 30,
forbid_high_risk_overlap: true,
max_high_risk_per_day: 2,
forbid_freeze_conflicts: true,
require_service: true,
}
}
///|
/// A policy that reports nothing: every limit is off, every flag is false. Use
/// it as the neutral element from which a caller builds its own thresholds.
pub fn Policy::relaxed() -> Policy {
{
max_concurrent: 0,
max_change_minutes: 0,
min_dependency_slack_minutes: 0,
forbid_high_risk_overlap: false,
max_high_risk_per_day: 0,
forbid_freeze_conflicts: false,
require_service: false,
}
}
///|
/// Render every setting as one `key = value` line, in declaration order.
///
/// Booleans render as `true` or `false`, a limit of `0` renders as `unlimited`,
/// and the result has no trailing newline.
pub fn Policy::describe(self : Policy) -> String {
let builder = StringBuilder()
builder.write_string(
"max_concurrent = " + limit_text(self.max_concurrent) + "\n",
)
builder.write_string(
"max_change_minutes = " + limit_text(self.max_change_minutes) + "\n",
)
builder.write_string(
"min_dependency_slack_minutes = " +
limit_text(self.min_dependency_slack_minutes) +
"\n",
)
builder.write_string(
"forbid_high_risk_overlap = " +
bool_text(self.forbid_high_risk_overlap) +
"\n",
)
builder.write_string(
"max_high_risk_per_day = " + limit_text(self.max_high_risk_per_day) + "\n",
)
builder.write_string(
"forbid_freeze_conflicts = " +
bool_text(self.forbid_freeze_conflicts) +
"\n",
)
builder.write_string("require_service = " + bool_text(self.require_service))
builder.to_string()
}
///|
/// Render a limit as a number, or as `unlimited` when it is off (`0`).
fn limit_text(value : Int) -> String {
if value == 0 {
"unlimited"
} else {
value.to_string()
}
}
///|
/// Render a flag that decides whether a rule runs.
fn bool_text(value : Bool) -> String {
if value {
"true"
} else {
"false"
}
}
///|
/// One rule breach: which rule, how bad, about what, and why.
///
/// `subject` is a window id, a service name or `all`, depending on the rule.
pub(all) struct PolicyViolation {
code : String
severity : Severity
subject : String
detail : String
}
///|
/// Render as `error max-concurrent (all): ...`, without a line number because a
/// policy speaks about the plan as a whole.
pub fn PolicyViolation::to_line(self : PolicyViolation) -> String {
self.severity.label() +
" " +
self.code +
" (" +
self.subject +
"): " +
self.detail
}
///|
/// An audit together with the policy it was judged against and every breach of
/// that policy.
pub(all) struct PolicyReport {
plan : Plan
audit : AuditReport
policy : Policy
violations : Array[PolicyViolation]
}
///|
/// Judge a plan against a policy.
///
/// The audit is computed exactly once with `audit(plan)` and stored unchanged,
/// so `report.audit` is always the same report the plain audit would return;
/// the policy only adds violations beside it.
pub fn audit_with_policy(plan : Plan, policy : Policy) -> PolicyReport {
let report = audit(plan)
{
plan,
audit: report,
policy,
violations: policy_violations(plan, report, policy),
}
}
///|
/// Number of policy violations, regardless of severity.
pub fn PolicyReport::violation_count(self : PolicyReport) -> Int {
self.violations.length()
}
///|
/// Policy errors plus the errors the underlying audit found.
pub fn PolicyReport::error_count(self : PolicyReport) -> Int {
let mut total = self.audit.error_count()
for violation in self.violations {
if violation.severity == Severity::Error {
total = total + 1
}
}
total
}
///|
/// Policy warnings plus the warnings the underlying audit found.
pub fn PolicyReport::warning_count(self : PolicyReport) -> Int {
let mut total = self.audit.warning_count()
for violation in self.violations {
if violation.severity == Severity::Warning {
total = total + 1
}
}
total
}
///|
/// True when either the audit or the policy found an error.
pub fn PolicyReport::has_errors(self : PolicyReport) -> Bool {
self.error_count() > 0
}
///|
/// `FAIL` when there is at least one error, `PASS` otherwise. A report whose
/// only findings are warnings stays at `PASS`.
pub fn PolicyReport::status(self : PolicyReport) -> String {
if self.has_errors() {
"FAIL"
} else {
"PASS"
}
}
///|
/// Highest number of simultaneous changes of one service.
///
/// The maximum is reached at the start of some change of that service, so the
/// search walks those start minutes and breaks ties towards the earliest one.
fn service_peak(plan : Plan, service : String) -> Int {
let mut best = 0
for change in plan.changes() {
if change.service == service {
let count = service_concurrency_at(plan, service, change.start)
if count > best {
best = count
}
}
}
best
}
///|
/// True when the plan holds a change with this id and high risk.
fn is_high_risk_change(plan : Plan, id : String) -> Bool {
match plan.find(id) {
Some(window) => window.is_change() && window.risk == Risk::High
None => false
}
}
///|
/// Length of the overlap between two changes of the plan, or `0` when they do
/// not overlap or when one of them is missing.
fn overlap_for_pair(plan : Plan, first_id : String, second_id : String) -> Int {
match (plan.find(first_id), plan.find(second_id)) {
(Some(first), Some(second)) =>
overlap_minutes(first.start, first.end, second.start, second.end)
_ => 0
}
}
///|
/// Every policy violation of a plan, in the fixed rule order:
///
/// 1. `max-concurrent`, 2. `max-change-minutes`, 3. `min-dependency-slack`,
/// 4. `high-risk-overlap`, 5. `max-high-risk-per-day`, 6. `freeze-conflict`,
/// 7. `service-missing`.
fn policy_violations(
plan : Plan,
report : AuditReport,
policy : Policy,
) -> Array[PolicyViolation] {
let violations : Array[PolicyViolation] = []
append_concurrency_violations(plan, policy, violations)
append_change_length_violations(plan, policy, violations)
append_slack_violations(plan, policy, violations)
append_high_risk_overlap_violations(plan, report, policy, violations)
append_daily_high_risk_violations(plan, policy, violations)
append_freeze_violations(report, policy, violations)
append_service_violations(plan, policy, violations)
violations
}
///|
/// Rule 1 `max-concurrent` (error): first the whole plan, then every service
/// that has changes, in code point order of the service names.
fn append_concurrency_violations(
plan : Plan,
policy : Policy,
violations : Array[PolicyViolation],
) -> Unit {
let limit = policy.max_concurrent
if limit <= 0 {
return
}
match peak_concurrency(plan) {
Some(peak) =>
if peak.count > limit {
violations.push({
code: "max-concurrent",
severity: Severity::Error,
subject: "all",
detail: peak.count.to_string() +
" simultaneous changes at " +
format_instant(peak.minute) +
" exceed the limit of " +
limit.to_string(),
})
}
None => ()
}
let services : Array[String] = []
for change in plan.changes() {
services.push(change.service)
}
for service in distinct_strings(services) {
let count = service_peak(plan, service)
if count > limit {
violations.push({
code: "max-concurrent",
severity: Severity::Error,
subject: service,
detail: count.to_string() +
" simultaneous changes at " +
format_instant(service_peak_minute(plan, service, count)) +
" exceed the limit of " +
limit.to_string(),
})
}
}
}
///|
/// First start minute at which a service reaches its peak; ties go to the
/// earliest change, which keeps the rendered detail stable.
fn service_peak_minute(plan : Plan, service : String, peak : Int) -> Int {
for change in plan.changes() {
if change.service == service &&
service_concurrency_at(plan, service, change.start) == peak {
return change.start
}
}
0
}
///|
/// Rule 2 `max-change-minutes` (error): every change longer than the limit, in
/// plan order. Freezes are never measured by this rule.
fn append_change_length_violations(
plan : Plan,
policy : Policy,
violations : Array[PolicyViolation],
) -> Unit {
let limit = policy.max_change_minutes
if limit <= 0 {
return
}
for change in plan.changes() {
let duration = change.duration()
if duration > limit {
violations.push({
code: "max-change-minutes",
severity: Severity::Error,
subject: change.id,
detail: "the change lasts " +
format_duration(duration) +
", the limit is " +
format_duration(limit),
})
}
}
}
///|
/// Rule 3 `min-dependency-slack` (warning): every dependency that exists in the
/// plan and leaves less than the required slack, in plan order and in
/// dependency order inside a change.
///
/// A negative slack means the dependency still runs when the dependent starts;
/// the detail then reports the overlap in those words instead of a bare
/// magnitude.
fn append_slack_violations(
plan : Plan,
policy : Policy,
violations : Array[PolicyViolation],
) -> Unit {
let limit = policy.min_dependency_slack_minutes
if limit <= 0 {
return
}
for change in plan.changes() {
for dependency_id in change.depends {
match plan.find(dependency_id) {
None => ()
Some(dependency) =>
if dependency.id != change.id {
let slack = change.start - dependency.end
if slack < limit {
let detail = if slack < 0 {
"overlaps " +
dependency.id +
" and misses the minimum slack of " +
format_duration(limit) +
" by " +
format_duration(slack)
} else {
"only " +
format_duration(slack) +
" of slack after " +
dependency.id +
", the minimum is " +
format_duration(limit)
}
violations.push({
code: "min-dependency-slack",
severity: Severity::Warning,
subject: change.id,
detail,
})
}
}
}
}
}
}
///|
/// Rule 4 `high-risk-overlap` (error): every overlapping pair the audit found
/// that involves at least one high risk change, reported once per pair and in
/// the order the audit produced the pairs.
fn append_high_risk_overlap_violations(
plan : Plan,
report : AuditReport,
policy : Policy,
violations : Array[PolicyViolation],
) -> Unit {
if !policy.forbid_high_risk_overlap {
return
}
for overlap in report.overlaps {
let first_high = is_high_risk_change(plan, overlap.first)
let second_high = is_high_risk_change(plan, overlap.second)
if !first_high && !second_high {
continue
}
let subject = if first_high { overlap.first } else { overlap.second }
let other = if first_high { overlap.second } else { overlap.first }
violations.push({
code: "high-risk-overlap",
severity: Severity::Error,
subject,
detail: "high risk " +
subject +
" overlaps " +
other +
" for " +
format_duration(overlap_for_pair(plan, subject, other)),
})
}
}
///|
/// Rule 5 `max-high-risk-per-day` (error): days with more high risk changes
/// than allowed, in ascending calendar order.
///
/// A change belongs to the day its start falls in, so a change that crosses
/// midnight counts towards the day it begins on. Ordering uses insertion sort
/// on the day index rather than a library sort, which keeps the output
/// reproducible.
fn append_daily_high_risk_violations(
plan : Plan,
policy : Policy,
violations : Array[PolicyViolation],
) -> Unit {
let limit = policy.max_high_risk_per_day
if limit <= 0 {
return
}
let days : Array[Int] = []
let counts : Array[Int] = []
for change in plan.changes() {
if change.risk == Risk::High {
let day = change.start / MINUTES_PER_DAY
match day_index(days, day) {
Some(position) => counts[position] = counts[position] + 1
None => {
days.push(day)
counts.push(1)
}
}
}
}
let order : Array[Int] = []
for index = 0; index < days.length(); index = index + 1 {
order.push(index)
}
let mut position = 1
while position < order.length() {
let current = order[position]
let mut scan = position
while scan > 0 && days[order[scan - 1]] > days[current] {
order[scan] = order[scan - 1]
scan = scan - 1
}
order[scan] = current
position = position + 1
}
for index in order {
if counts[index] > limit {
violations.push({
code: "max-high-risk-per-day",
severity: Severity::Error,
subject: format_instant(days[index] * MINUTES_PER_DAY),
detail: counts[index].to_string() +
" high risk changes on this day, the limit is " +
limit.to_string(),
})
}
}
}
///|
/// Position of a value in an array, or `None`.
fn day_index(values : Array[Int], value : Int) -> Int? {
for index = 0; index < values.length(); index = index + 1 {
if values[index] == value {
return Some(index)
}
}
None
}
///|
/// Rule 6 `freeze-conflict` (error): one violation per conflict the audit
/// found, so a change inside two freezes produces two violations.
fn append_freeze_violations(
report : AuditReport,
policy : Policy,
violations : Array[PolicyViolation],
) -> Unit {
if !policy.forbid_freeze_conflicts {
return
}
for conflict in report.freeze_conflicts {
violations.push({
code: "freeze-conflict",
severity: Severity::Error,
subject: conflict.window_id,
detail: "runs inside freeze " +
conflict.freeze_id +
" for " +
format_duration(conflict.minutes),
})
}
}
///|
/// Rule 7 `service-missing` (warning): every change without a service name, in
/// plan order.
fn append_service_violations(
plan : Plan,
policy : Policy,
violations : Array[PolicyViolation],
) -> Unit {
if !policy.require_service {
return
}
for change in plan.changes() {
if change.service == "" {
violations.push({
code: "service-missing",
severity: Severity::Warning,
subject: change.id,
detail: "no service is named for this change",
})
}
}
}
///|
/// Render a policy report.
///
/// The header is followed by the untouched audit report, then by the policy
/// section: the two-space-indented violations, or ` (none)`, and finally the
/// `policy: ` line. The string ends with exactly one newline.
pub fn render_policy_report(report : PolicyReport) -> String {
let builder = StringBuilder()
builder.write_string("Moon Change Window Policy Report\n")
builder.write_string(render_report(report.audit))
builder.write_char('\n')
builder.write_string("POLICY VIOLATIONS\n")
if report.violations.length() == 0 {
builder.write_string(" (none)\n")
} else {
for violation in report.violations {
builder.write_string(" ")
builder.write_string(violation.to_line())
builder.write_char('\n')
}
}
builder.write_string("policy: " + report.status() + "\n")
builder.to_string()
}
///|
/// Render violations one per line, without indentation. An empty list renders
/// as an empty string.
pub fn render_violations(violations : Array[PolicyViolation]) -> String {
let builder = StringBuilder()
for index = 0; index < violations.length(); index = index + 1 {
if index > 0 {
builder.write_char('\n')
}
builder.write_string(violations[index].to_line())
}
builder.to_string()
}