Files
cloud-ip-validator/internal/analytics/compare.go
T

432 lines
13 KiB
Go
Raw Normal View History

2026-10-04 10:26:37 +03:00
package analytics
import (
"sort"
"strings"
"cloudipvalidator/internal/db"
)
// indicator is one of the seven counters of the analytics page. Its key is the
// name of the list behind the counter; has repeats the condition Compute counts
// it by (an address with a cancelled result is never asked).
type indicator struct {
key, name string
has func(a *addr) bool
}
var indicators = []indicator{
{ListVerdictPass, "pass", func(a *addr) bool { return a.res.Verdict == db.ResultPass }},
{ListVerdictPartial, "partial", func(a *addr) bool { return a.res.Verdict == db.ResultPartial }},
{ListVerdictFail, "fail", func(a *addr) bool { return a.res.Verdict == db.ResultFail }},
{ListEgressHTTPSAny, "Egress https: есть провалы", func(a *addr) bool { return a.https.n > 0 && a.https.ok < a.https.n }},
{ListEgressHTTPSAll, "Egress https: все провалены", func(a *addr) bool { return a.https.n > 0 && a.https.ok == 0 }},
{ListIngressSSHAny, "Ingress ssh: есть провалы", func(a *addr) bool { return a.ssh.n > 0 && a.ssh.ok < a.ssh.n }},
{ListIngressSSHAll, "Ingress ssh: все провалены", func(a *addr) bool { return a.ssh.n > 0 && a.ssh.ok == 0 }},
}
// indicatorIndex is the position of an indicator key in the table, -1 if unknown.
func indicatorIndex(key string) int {
for i, ind := range indicators {
if ind.key == key {
return i
}
}
return -1
}
// verdicts are the rows and columns of the transition matrix.
var verdicts = []string{db.ResultPass, db.ResultPartial, db.ResultFail}
func verdictIndex(v string) int {
for i, x := range verdicts {
if x == v {
return i
}
}
return -1
}
// membership is the set of indicators an address belongs to, one bit per
// entry of the indicators table.
func membership(a *addr) uint8 {
var m uint8
for i, ind := range indicators {
if ind.has(a) {
m |= 1 << i
}
}
return m
}
// Comparison is the difference between two finished runs: the base (older) and
// the target (newer) one. An address is its IP; one with a cancelled result is
// not in its run.
type Comparison struct {
Runs CompareRuns `json:"runs"`
Groups CompareGroups `json:"groups"`
Indicators []IndicatorDiff `json:"indicators"`
Transitions Transitions `json:"transitions"`
Cancelled CompareCancel `json:"cancelled"`
rows []*cmpRow // numeric address order
target *Analysis
}
type CompareRuns struct {
Base CompareRun `json:"base"`
Target CompareRun `json:"target"`
}
type CompareRun struct {
RunInfo
Addresses int `json:"addresses"`
}
// CompareGroups counts the addresses by how they relate to the two runs:
// New are only in the target, Left only in the base, Common in both, and
// Common = Changed + Same.
type CompareGroups struct {
New int `json:"new"`
Left int `json:"left"`
Common int `json:"common"`
Changed int `json:"changed"`
Same int `json:"same"`
}
// IndicatorDiff is one indicator in both runs. Delta = Target - Base =
// New - Left + Entered - Exited.
type IndicatorDiff struct {
Key string `json:"key"`
Name string `json:"name"`
Base int `json:"base"`
Target int `json:"target"`
Delta int `json:"delta"`
New int `json:"new"` // new addresses that are in the indicator
Left int `json:"left"` // left addresses that were in it
Entered int `json:"entered"` // common addresses that entered it
Exited int `json:"exited"` // common addresses that left it
}
// Transitions is the verdict of the common addresses: Matrix[from][to] with
// the verdicts of the base on the rows and of the target on the columns. New
// holds the new addresses by their verdict in the target, Left the addresses
// that left by their verdict in the base.
type Transitions struct {
Verdicts []string `json:"verdicts"`
Matrix [][]int `json:"matrix"`
New []int `json:"new"`
Left []int `json:"left"`
}
type CompareCancel struct {
Base int `json:"base"`
Target int `json:"target"`
}
// cmpRow is one address with its state in each run (nil when absent) and the
// indicators it belongs to there.
type cmpRow struct {
ip string
a, b *addr
ma, mb uint8
}
func (r *cmpRow) common() bool { return r.a != nil && r.b != nil }
func (r *cmpRow) changed() bool { return r.common() && r.ma != r.mb }
// Compare puts two analyses side by side; base is the older run, target the newer.
func Compare(base, target *Analysis) *Comparison {
byIP := map[string]*cmpRow{}
var rows []*cmpRow
add := func(list []*addr, isBase bool) {
for _, x := range list {
r := byIP[x.res.IPAddress]
if r == nil {
r = &cmpRow{ip: x.res.IPAddress}
byIP[r.ip] = r
rows = append(rows, r)
}
if isBase {
r.a, r.ma = x, membership(x)
} else {
r.b, r.mb = x, membership(x)
}
}
}
add(base.sorted(), true)
add(target.sorted(), false)
sort.Slice(rows, func(i, j int) bool { return lessIP(rows[i].ip, rows[j].ip) })
c := &Comparison{rows: rows, target: target}
c.Runs = CompareRuns{
Base: CompareRun{RunInfo: base.Report.Run, Addresses: base.Report.Summary.Addresses},
Target: CompareRun{RunInfo: target.Report.Run, Addresses: target.Report.Summary.Addresses},
}
c.Cancelled = CompareCancel{Base: base.Report.Summary.Cancelled, Target: target.Report.Summary.Cancelled}
c.Indicators = make([]IndicatorDiff, len(indicators))
for i, ind := range indicators {
c.Indicators[i] = IndicatorDiff{Key: ind.key, Name: ind.name}
}
tr := &c.Transitions
tr.Verdicts = verdicts
tr.New, tr.Left = make([]int, len(verdicts)), make([]int, len(verdicts))
tr.Matrix = make([][]int, len(verdicts))
for i := range tr.Matrix {
tr.Matrix[i] = make([]int, len(verdicts))
}
for _, r := range rows {
switch {
case r.a == nil:
c.Groups.New++
if v := verdictIndex(r.b.res.Verdict); v >= 0 {
tr.New[v]++
}
case r.b == nil:
c.Groups.Left++
if v := verdictIndex(r.a.res.Verdict); v >= 0 {
tr.Left[v]++
}
default:
c.Groups.Common++
if r.changed() {
c.Groups.Changed++
} else {
c.Groups.Same++
}
if from, to := verdictIndex(r.a.res.Verdict), verdictIndex(r.b.res.Verdict); from >= 0 && to >= 0 {
tr.Matrix[from][to]++
}
}
for i := range indicators {
bit := uint8(1) << i
inA, inB := r.ma&bit != 0, r.mb&bit != 0
d := &c.Indicators[i]
if inA {
d.Base++
}
if inB {
d.Target++
}
switch {
case r.a == nil && inB:
d.New++
case r.b == nil && inA:
d.Left++
case r.common() && !inA && inB:
d.Entered++
case r.common() && inA && !inB:
d.Exited++
}
}
}
for i := range c.Indicators {
c.Indicators[i].Delta = c.Indicators[i].Target - c.Indicators[i].Base
}
return c
}
// Groups served by Comparison.List.
const (
GroupNew = "new"
GroupLeft = "left"
GroupCommon = "common"
GroupChanged = "changed"
GroupSame = "same"
GroupEntered = "entered"
GroupExited = "exited"
)
// CompareFilter narrows a group. Indicator is a list key of the indicator
// table: for new and left the address is in it in its own run, for common,
// changed and same in either run, for entered and exited it is required.
// From and To, only together, keep the common addresses whose verdict was From
// in the base and is To in the target.
type CompareFilter struct {
Indicator string
From, To string
}
// CompareList is a table of addresses of one group.
type CompareList struct {
Group string `json:"group"`
Indicator string `json:"indicator,omitempty"`
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
// List builds the table of a group; an unknown group, indicator or verdict, or
// a filter that does not fit the group, is an ErrUnknownList.
func (c *Comparison) List(group string, f CompareFilter) (*CompareList, error) {
switch group {
case GroupNew, GroupLeft, GroupCommon, GroupChanged, GroupSame, GroupEntered, GroupExited:
default:
return nil, ErrUnknownList(group)
}
var bit uint8
if f.Indicator != "" {
i := indicatorIndex(f.Indicator)
if i < 0 {
return nil, ErrUnknownList("indicator " + f.Indicator)
}
bit = 1 << i
}
if bit == 0 && (group == GroupEntered || group == GroupExited) {
return nil, ErrUnknownList(group + " without indicator")
}
if (f.From != "") != (f.To != "") {
return nil, ErrUnknownList("from without to")
}
if f.From != "" {
if verdictIndex(f.From) < 0 || verdictIndex(f.To) < 0 {
return nil, ErrUnknownList("verdict " + f.From + " → " + f.To)
}
if group == GroupNew || group == GroupLeft {
return nil, ErrUnknownList("from and to for " + group)
}
}
l := &CompareList{Group: group, Indicator: f.Indicator, Rows: [][]string{}}
if group == GroupNew || group == GroupLeft {
l.Columns = []string{"Адрес", "Подсеть", "Вердикт", "Egress", "Ingress", "Индикаторы"}
} else {
l.Columns = []string{"Адрес", "Подсеть", "Вердикт (A → B)", "Egress (A → B)", "Ingress (A → B)", "Что изменилось"}
}
for _, r := range c.rows {
var ok bool
switch group {
case GroupNew:
ok = r.a == nil && (bit == 0 || r.mb&bit != 0)
case GroupLeft:
ok = r.b == nil && (bit == 0 || r.ma&bit != 0)
case GroupCommon:
ok = r.common()
case GroupChanged:
ok = r.changed()
case GroupSame:
ok = r.common() && !r.changed()
case GroupEntered:
ok = r.common() && r.ma&bit == 0 && r.mb&bit != 0
case GroupExited:
ok = r.common() && r.ma&bit != 0 && r.mb&bit == 0
}
if !ok {
continue
}
if r.common() && bit != 0 && group != GroupEntered && group != GroupExited && (r.ma|r.mb)&bit == 0 {
continue
}
if f.From != "" && (r.a.res.Verdict != f.From || r.b.res.Verdict != f.To) {
continue
}
switch {
case r.a == nil:
l.Rows = append(l.Rows, []string{r.ip, r.b.subnet, r.b.res.Verdict, okOf(r.b.egress), okOf(r.b.ingress), indicatorsCell(r.mb)})
case r.b == nil:
l.Rows = append(l.Rows, []string{r.ip, r.a.subnet, r.a.res.Verdict, okOf(r.a.egress), okOf(r.a.ingress), indicatorsCell(r.ma)})
default:
l.Rows = append(l.Rows, []string{r.ip, r.b.subnet, arrow(r.a.res.Verdict, r.b.res.Verdict),
arrow(okOf(r.a.egress), okOf(r.b.egress)), arrow(okOf(r.a.ingress), okOf(r.b.ingress)), c.changeText(r)})
}
}
return l, nil
}
func arrow(from, to string) string { return from + " → " + to }
// indicatorsCell names the indicators of a membership set, "—" for none.
func indicatorsCell(m uint8) string {
var names []string
for i, ind := range indicators {
if m&(1<<i) != 0 {
names = append(names, ind.name)
}
}
if len(names) == 0 {
return "—"
}
return strings.Join(names, ", ")
}
// changeText says step by step what differs between the two runs for a common
// address. An address with the same indicators is "без изменений" even if its
// failed targets or sites differ.
func (c *Comparison) changeText(r *cmpRow) string {
if r.ma == r.mb {
return "без изменений"
}
var steps []string
if r.a.res.Verdict != r.b.res.Verdict {
steps = append(steps, "вердикт "+arrow(r.a.res.Verdict, r.b.res.Verdict))
}
var in, out []string
for i, ind := range indicators {
if strings.HasPrefix(ind.key, "verdict_") {
continue // the verdict step says it
}
bit := uint8(1) << i
switch {
case r.ma&bit == 0 && r.mb&bit != 0:
in = append(in, ind.name)
case r.ma&bit != 0 && r.mb&bit == 0:
out = append(out, ind.name)
}
}
if len(in) > 0 {
steps = append(steps, "вошёл в: "+strings.Join(in, ", "))
}
if len(out) > 0 {
steps = append(steps, "вышел из: "+strings.Join(out, ", "))
}
if added, removed := setDiff(r.a.https.failedTargets, r.b.https.failedTargets, sortedStrings); len(added)+len(removed) > 0 {
steps = append(steps, "https: провалены цели "+signed(added, removed))
}
if added, removed := setDiff(r.a.ssh.sites, r.b.ssh.sites, c.target.sortSites); len(added)+len(removed) > 0 {
steps = append(steps, "ssh: площадки "+signed(added, removed))
}
if was, now := r.a.https.validator, r.b.https.validator; was != "" && now != "" && was != now {
steps = append(steps, "валидатор "+arrow(ShortValidator(was), ShortValidator(now)))
}
return strings.Join(steps, "; ")
}
// setDiff is what is in now and not in was, and the reverse, each ordered by order.
func setDiff(was, now []string, order func([]string) []string) (added, removed []string) {
in := func(list []string) map[string]bool {
m := make(map[string]bool, len(list))
for _, s := range list {
m[s] = true
}
return m
}
w, n := in(was), in(now)
for s := range n {
if !w[s] {
added = append(added, s)
}
}
for s := range w {
if !n[s] {
removed = append(removed, s)
}
}
return order(added), order(removed)
}
func sortedStrings(s []string) []string {
sort.Strings(s)
return s
}
// signed writes a set difference as "+new1 +new2 −gone1".
func signed(added, removed []string) string {
parts := make([]string, 0, len(added)+len(removed))
for _, s := range added {
parts = append(parts, "+"+s)
}
for _, s := range removed {
parts = append(parts, "−"+s)
}
return strings.Join(parts, " ")
}