Compare commits
3 Commits
c67343b7cf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fbe592421 | |||
| 6d36b4e958 | |||
| 90c6c80843 |
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
import datetime, json, os, re, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
SECRETS = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
INPUTS = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
@@ -61,6 +61,44 @@ RANGE_FILTERS = [
|
||||
]
|
||||
|
||||
|
||||
# A bound on first_seen / last_seen can also be relative to now ("24h", "7d",
|
||||
# "30m"), so a scheduled playbook counts the sensors seen in the last day without
|
||||
# computing a date. It is turned into the UTC instant here.
|
||||
RELATIVE = re.compile(r"^\s*(\d+)\s*([mhd])\s*$", re.I)
|
||||
UNITS = {"m": "minutes", "h": "hours", "d": "days"}
|
||||
|
||||
|
||||
def instant(v):
|
||||
m = RELATIVE.match(str(v))
|
||||
if not m:
|
||||
return v
|
||||
delta = datetime.timedelta(**{UNITS[m.group(2).lower()]: int(m.group(1))})
|
||||
return (datetime.datetime.now(datetime.timezone.utc) - delta).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# Sensor version bounds. FQL compares agent_version as text, where 7.9 sorts
|
||||
# above 7.18, so these are compared here, number by number, over the devices the
|
||||
# other filters keep.
|
||||
VERSION_BOUNDS = {
|
||||
"agent_version_lt": lambda v, t: v < t,
|
||||
"agent_version_lte": lambda v, t: v <= t,
|
||||
"agent_version_gt": lambda v, t: v > t,
|
||||
"agent_version_gte": lambda v, t: v >= t,
|
||||
}
|
||||
SCROLL = 5000
|
||||
DETAILS = 1000
|
||||
|
||||
|
||||
def parse_version(v):
|
||||
parts = re.findall(r"\d+", str(v or ""))
|
||||
return tuple(int(x) for x in parts) if parts else None
|
||||
|
||||
|
||||
def pad(a, b):
|
||||
n = max(len(a), len(b))
|
||||
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
|
||||
|
||||
|
||||
def literal(v):
|
||||
# FQL quotes strings with single quotes, so a value carrying one would end
|
||||
# the literal early and change what the query means.
|
||||
@@ -82,7 +120,7 @@ def build_fql(inputs):
|
||||
for name, prop, operator in RANGE_FILTERS:
|
||||
value = inputs.get(name)
|
||||
if value:
|
||||
clauses.append(prop + ":" + operator + literal(value))
|
||||
clauses.append(prop + ":" + operator + literal(instant(value)))
|
||||
# Escape hatch for any property this command does not name explicitly: raw
|
||||
# FQL, ANDed with the rest.
|
||||
raw = str(inputs.get("filter") or "").strip()
|
||||
@@ -110,9 +148,59 @@ def count(tok, fql):
|
||||
return int(total)
|
||||
|
||||
|
||||
def version_bounds(inputs):
|
||||
bounds = {}
|
||||
for field in VERSION_BOUNDS:
|
||||
value = str(inputs.get(field) or "").strip()
|
||||
if value:
|
||||
target = parse_version(value)
|
||||
if target is None:
|
||||
raise ValueError(field + " must be a version such as 7.18.18803, got: " + repr(value))
|
||||
bounds[field] = target
|
||||
return bounds
|
||||
|
||||
|
||||
def count_by_version(tok, fql, bounds):
|
||||
"""Every device ID the filter keeps, then their sensor versions, compared
|
||||
here. A device with no readable version is left out and counted apart."""
|
||||
ids, offset = [], None
|
||||
while True:
|
||||
params = [("limit", SCROLL)]
|
||||
if fql:
|
||||
params.insert(0, ("filter", fql))
|
||||
if offset:
|
||||
params.append(("offset", offset))
|
||||
reply = call("GET", "/devices/queries/devices-scroll/v1", tok, params=params)
|
||||
page = reply.get("resources") or []
|
||||
ids.extend(page)
|
||||
pagination = (reply.get("meta") or {}).get("pagination") or {}
|
||||
offset = pagination.get("offset")
|
||||
if not page or not offset or len(ids) >= int(pagination.get("total") or 0):
|
||||
break
|
||||
matched, unreadable = 0, 0
|
||||
for i in range(0, len(ids), DETAILS):
|
||||
reply = call("POST", "/devices/entities/devices/v2", tok, body={"ids": ids[i:i + DETAILS]})
|
||||
for device in reply.get("resources") or []:
|
||||
version = parse_version(device.get("agent_version"))
|
||||
if version is None:
|
||||
unreadable += 1
|
||||
elif all(VERSION_BOUNDS[f](*pad(version, t)) for f, t in bounds.items()):
|
||||
matched += 1
|
||||
return matched, len(ids), unreadable
|
||||
|
||||
|
||||
def main():
|
||||
tok = token()
|
||||
fql = build_fql(INPUTS)
|
||||
bounds = version_bounds(INPUTS)
|
||||
if bounds:
|
||||
matched, scanned, unreadable = count_by_version(tok, fql, bounds)
|
||||
result = {"count": matched, "filter": fql,
|
||||
"agent_version_bounds": {f: INPUTS[f] for f in bounds},
|
||||
"scanned": scanned, "unreadable_version": unreadable}
|
||||
result["total"] = count(tok, "")
|
||||
print(json.dumps(result))
|
||||
return
|
||||
result = {"count": count(tok, fql), "filter": fql}
|
||||
# The estate total is the denominator the filtered count is read against: it
|
||||
# turns a bare number into a proportion, and makes a filter that matched the
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
||||
import json, os, re, sys, time, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
|
||||
RETRIES = 3
|
||||
@@ -70,6 +70,28 @@ RANGE_FILTERS = {
|
||||
}
|
||||
|
||||
|
||||
# Version bounds. The API matches a version only exactly, and a range lookup it
|
||||
# does not support would be dropped without a word — so these are compared here,
|
||||
# number by number (4.10 is above 4.9, which a string comparison denies).
|
||||
VERSION_BOUNDS = {
|
||||
"version_lt": lambda v, t: v < t,
|
||||
"version_lte": lambda v, t: v <= t,
|
||||
"version_gt": lambda v, t: v > t,
|
||||
"version_gte": lambda v, t: v >= t,
|
||||
}
|
||||
PAGE = 500
|
||||
|
||||
|
||||
def parse_version(v):
|
||||
parts = re.findall(r"\d+", str(v or ""))
|
||||
return tuple(int(x) for x in parts) if parts else None
|
||||
|
||||
|
||||
def pad(a, b):
|
||||
n = max(len(a), len(b))
|
||||
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
|
||||
|
||||
|
||||
def to_bool(v):
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
@@ -86,7 +108,13 @@ def main():
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
# === per-command logic ===
|
||||
filters = {}
|
||||
# An instance bound to one tenant of a shared, multi-tenant manager carries it
|
||||
# in its config: it scopes the count, and the fleet total beside it, unless the
|
||||
# block names a tenant itself.
|
||||
scope = {}
|
||||
if not str(inputs.get("tenant") or "").strip() and str(secrets.get("tenant") or "").strip():
|
||||
scope["tenant"] = str(secrets["tenant"]).strip()
|
||||
filters = dict(scope)
|
||||
for field in EXACT_FILTERS:
|
||||
value = inputs.get(field)
|
||||
if value is None or value == "":
|
||||
@@ -103,17 +131,48 @@ def main():
|
||||
if len(kv) == 2 and kv[0].strip():
|
||||
filters[kv[0].strip()] = kv[1]
|
||||
|
||||
bounds = {}
|
||||
for field in VERSION_BOUNDS:
|
||||
value = str(inputs.get(field) or "").strip()
|
||||
if value:
|
||||
target = parse_version(value)
|
||||
if target is None:
|
||||
raise ValueError(field + " must be a version such as 4.10.2, got: " + repr(value))
|
||||
bounds[field] = target
|
||||
|
||||
# The agent list is a DRF paginated collection: `count` is the total number
|
||||
# of matches, independent of the page size. limit=1 keeps the payload tiny.
|
||||
agents = base + "/api/data/endpoint/Agent/"
|
||||
if bounds:
|
||||
# A version bound reads every agent the other filters keep and counts
|
||||
# here. An agent with no readable version is left out and counted apart,
|
||||
# rather than guessed above or below the bound.
|
||||
count, scanned, unreadable, offset = 0, 0, 0, 0
|
||||
while True:
|
||||
page = request("GET", agents + qs(dict(filters, limit=PAGE, offset=offset)), headers)
|
||||
rows = page.get("results") or []
|
||||
for agent in rows:
|
||||
scanned += 1
|
||||
version = parse_version(agent.get("version"))
|
||||
if version is None:
|
||||
unreadable += 1
|
||||
continue
|
||||
if all(VERSION_BOUNDS[f](*pad(version, t)) for f, t in bounds.items()):
|
||||
count += 1
|
||||
offset += len(rows)
|
||||
if not rows or offset >= page.get("count", 0):
|
||||
break
|
||||
result = {"count": count, "filters": dict(filters, **{f: inputs[f] for f in bounds}),
|
||||
"scanned": scanned, "unreadable_version": unreadable}
|
||||
else:
|
||||
matched = request("GET", agents + qs(dict(filters, limit=1, offset=0)), headers)
|
||||
result = {"count": matched.get("count", 0), "filters": filters}
|
||||
|
||||
# The API silently drops a query parameter it does not recognise as a
|
||||
# filter, which yields the unfiltered total dressed up as a filtered count.
|
||||
# Returning the fleet total alongside makes that failure visible.
|
||||
if filters:
|
||||
everything = request("GET", agents + qs({"limit": 1, "offset": 0}), headers)
|
||||
if filters != scope or bounds:
|
||||
everything = request("GET", agents + qs(dict(scope, limit=1, offset=0)), headers)
|
||||
result["total"] = everything.get("count", 0)
|
||||
else:
|
||||
result["total"] = result["count"]
|
||||
|
||||
Reference in New Issue
Block a user