blob: 845c0cd528d216acbe9b4b2497ff5fa29317efbf [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""The organization scanner.
One scan of one organization is:
1. a single cheap "meta" query for the organization's database ID and its
member, team and repository totals,
2. members, teams and repositories fetched concurrently, each skipping whatever
the cache says cannot have changed (see cache.py),
3. a snapshot written to $org-$timestamp.yaml, with old snapshots rotated out.
Every query asks for GitHub's rateLimit block, which is free and keeps the
client's idea of the remaining budget current, and every paginated sweep
carries an adaptive page size that shrinks if GitHub times out.
Teams are the expensive part of a large organization: there are thousands of
them, each with its own member list. Instead of one round trip per team,
matching teams are requested several per GraphQL document using field aliases.
"""
from __future__ import annotations
import asyncio
import dataclasses
import datetime
import logging
import random
import time
import typing
import aiohttp
from . import cache as _cache
from . import config as _config
from . import ghclient
from . import writer
from .ghtypes import GraphQLData, as_dict, as_int, as_list, as_optional_bool, as_optional_int, as_str, page_info
from .snapshot import Member, OrgSnapshot, Repository, ScanStats, Team
LOGGER = logging.getLogger("orgscanner.scanner")
VERSION = "1.0.0"
RATE_LIMIT_FRAGMENT = "rateLimit { limit cost remaining used resetAt }"
META_QUERY = """
query OrgMeta($org: String!) {
organization(login: $org) {
login
databaseId
membersWithRole(first: 1) { totalCount }
teams(first: 1) { totalCount }
repositories(first: 1) { totalCount }
}
%s
}
""" % RATE_LIMIT_FRAGMENT
MEMBERS_QUERY = """
query OrgMembers($org: String!, $size: Int!, $after: String) {
organization(login: $org) {
membersWithRole(first: $size, after: $after) {
totalCount
pageInfo { hasNextPage endCursor }
edges {
role
hasTwoFactorEnabled
node { login databaseId name }
}
}
}
%s
}
""" % RATE_LIMIT_FRAGMENT
TEAMS_QUERY = """
query OrgTeams($org: String!, $size: Int!, $after: String) {
organization(login: $org) {
teams(first: $size, after: $after, orderBy: {field: NAME, direction: ASC}) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
slug
name
databaseId
privacy
members { totalCount }
}
}
}
%s
}
""" % RATE_LIMIT_FRAGMENT
REPOSITORIES_QUERY = """
query OrgRepositories($org: String!, $size: Int!, $after: String) {
organization(login: $org) {
repositories(first: $size, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
name
databaseId
visibility
url
isArchived
updatedAt
}
}
}
%s
}
""" % RATE_LIMIT_FRAGMENT
class OrgMeta(typing.TypedDict):
"""What the meta query tells us about an organization."""
id: int | None
members: int
teams: int
repositories: int
class TeamMeta(typing.TypedDict):
"""A team from the team listing, before its members have been read."""
slug: str
name: str
id: int | None
privacy: str
member_count: int
@dataclasses.dataclass
class TeamScanStats:
"""How much of the team work a scan had to do."""
refreshed: int = 0
cached: int = 0
all_cached: bool = False
def build_team_members_query(count: int) -> str:
"""Build a document fetching member pages for `count` teams at once.
Slugs and cursors go in as variables ($s0, $c0 and so on) rather than being
interpolated into the document, so a team name coming back from GitHub can
never alter the query.
"""
assert count > 0, "A team member batch needs at least one team"
declarations = ["$org: String!", "$size: Int!"]
selections = []
for index in range(count):
declarations.append(f"$s{index}: String!")
declarations.append(f"$c{index}: String")
selections.append(
f" t{index}: team(slug: $s{index}) {{\n"
f" slug\n"
f" members(first: $size, after: $c{index}) {{\n"
f" totalCount\n"
f" pageInfo {{ hasNextPage endCursor }}\n"
f" nodes {{ login }}\n"
f" }}\n"
f" }}"
)
return "query TeamMembers(%s) {\n organization(login: $org) {\n%s\n }\n %s\n}\n" % (
", ".join(declarations),
"\n".join(selections),
RATE_LIMIT_FRAGMENT,
)
T = typing.TypeVar("T")
def _completed(outcome: T | BaseException) -> T:
"""Re-raise a gathered failure, or hand back its result."""
if isinstance(outcome, BaseException):
raise outcome
return outcome
class OrgScanner:
"""Scans GitHub organizations and writes YAML snapshots.
Can be used one shot (await scanner.scan_all()), as a long lived task inside
Boxer (asyncio.create_task(scanner.run_forever())), or standalone through
python -m orgscanner. Keeping one instance around across scans keeps the
in-memory cache and the HTTP connection pool warm.
"""
def __init__(
self,
config: _config.Configuration,
client: ghclient.QueryClient | None = None,
session: aiohttp.ClientSession | None = None,
):
self.config = config
self.client: ghclient.QueryClient = client or ghclient.GraphQLClient(config, session=session)
self.cache_store = _cache.CacheStore(config.cache)
self.snapshots: dict[str, OrgSnapshot] = {}
self.errors: dict[str, str] = {}
self._caches: dict[str, _cache.OrgCache] = {}
async def __aenter__(self) -> OrgScanner:
await self.client.session()
return self
async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
await self.close()
async def close(self) -> None:
await self.client.close()
# -- public API --------------------------------------------------------
async def scan_all(
self,
organizations: typing.Sequence[str] | None = None,
*,
full: bool = False,
write: bool = True,
) -> dict[str, OrgSnapshot]:
"""Scan every configured organization, one after the other.
A failure on one organization is logged and recorded in self.errors, and
the rest are still scanned. The returned mapping only holds the
organizations that scanned successfully.
"""
targets = list(organizations or self.config.scan.organizations)
results: dict[str, OrgSnapshot] = {}
self.errors = {}
for index, org in enumerate(targets):
if index and self.config.scan.organization_delay:
await asyncio.sleep(self.config.scan.organization_delay)
try:
results[org] = await self.scan_organization(org, full=full, write=write)
except asyncio.CancelledError:
raise
except Exception as e: # Deliberately broad: one bad org must not stop the rest
self.errors[org] = str(e)
LOGGER.error(
"Scan of organization %s failed: %s", org, e, exc_info=LOGGER.isEnabledFor(logging.DEBUG)
)
return results
async def scan_organization(self, organization: str, *, full: bool = False, write: bool = True) -> OrgSnapshot:
"""Scan one organization and, by default, write its snapshot to disk."""
started = time.time()
cache = self._cache_for(organization)
full_scan = bool(full or cache.is_empty or self._due_for_full_scan(cache))
requests_before = self.client.requests
points_before = self.client.points_spent
retries_before = self.client.retries
LOGGER.info(
"Scanning organization %s (%s scan, cache serial %d)",
organization,
"full" if full_scan else "incremental",
cache.scan_serial,
)
meta = await self._fetch_meta(organization)
cache.organization_id = meta["id"] or cache.organization_id
page_sizes: list[ghclient.AdaptivePageSize] = []
# Members, teams and repositories are independent sweeps, and overlapping
# them hides the per-request latency that dominates a large organization.
member_outcome, team_outcome, repository_outcome = await asyncio.gather(
self._fetch_members(organization, cache, meta["members"], full_scan, page_sizes),
self._fetch_teams(organization, cache, meta["teams"], full_scan, page_sizes),
self._fetch_repositories(organization, cache, meta["repositories"], full_scan, page_sizes),
return_exceptions=True,
)
members, members_cached = _completed(member_outcome)
teams, team_stats = _completed(team_outcome)
repositories, repositories_cached = _completed(repository_outcome)
now = time.time()
cache.scan_serial += 1
cache.last_scan = now
if full_scan:
cache.last_full_scan = now
self.cache_store.save(cache)
stats = ScanStats(
full_scan=full_scan,
started_at=started,
finished_at=now,
graphql_requests=self.client.requests - requests_before,
graphql_points=self.client.points_spent - points_before,
graphql_retries=self.client.retries - retries_before,
rate_limit_remaining=self.client.rate_limit.remaining if self.client.rate_limit.known else None,
rate_limit_limit=self.client.rate_limit.limit if self.client.rate_limit.known else None,
teams_refreshed=team_stats.refreshed,
teams_from_cache=team_stats.cached,
repositories_from_cache=repositories_cached,
page_size_reductions=sum(page.shrinks for page in page_sizes),
)
if members_cached:
stats.reused_from_cache.append("members")
if team_stats.all_cached:
stats.reused_from_cache.append("teams")
if repositories_cached and not full_scan:
stats.reused_from_cache.append("repositories")
snapshot = OrgSnapshot(
organization=organization,
organization_id=cache.organization_id,
timestamp=datetime.datetime.now(datetime.timezone.utc),
team_patterns=list(self.config.scan.team_patterns),
members=members,
teams=teams,
repositories=repositories,
stats=stats,
scanner_version=VERSION,
)
snapshot.sort()
self.snapshots[organization] = snapshot
if write:
writer.write_snapshot(self.config.output, snapshot)
counts = snapshot.counts()
LOGGER.info(
"Scanned %s in %.1fs: %d members (%d without 2FA), %d matched teams, %d repositories "
"[%d requests, %d points, %d/%d budget left]",
organization,
stats.duration,
counts["members"],
counts["members_without_2fa"],
counts["teams"],
counts["repositories"],
stats.graphql_requests,
stats.graphql_points,
stats.rate_limit_remaining if stats.rate_limit_remaining is not None else -1,
stats.rate_limit_limit if stats.rate_limit_limit is not None else -1,
)
return snapshot
async def run_forever(self, stop_event: asyncio.Event | None = None) -> None:
"""Scan every organization on a loop until stop_event is set.
This is the entry point for the standalone daemon and for a Boxer
background task. The wait between rounds is scan.interval measured from
the end of the previous round, plus up to scan.jitter seconds so that
several scanners sharing a token do not line up.
"""
stop_event = stop_event or asyncio.Event()
while not stop_event.is_set():
round_started = time.time()
try:
await self.scan_all()
except asyncio.CancelledError:
raise
except Exception as e: # Never let the loop die on a bad round
LOGGER.error("Scan round failed: %s", e, exc_info=LOGGER.isEnabledFor(logging.DEBUG))
elapsed = time.time() - round_started
delay = max(0.0, self.config.scan.interval - elapsed)
if self.config.scan.jitter:
delay += random.uniform(0, self.config.scan.jitter)
# If the hourly budget is spent, wait out the reset rather than
# starting a round that will only park on its first query.
budget_wait = self._budget_wait()
if budget_wait > delay:
LOGGER.warning(
"GraphQL budget nearly spent (%d left); sleeping %.0fs until it resets",
self.client.rate_limit.remaining,
budget_wait,
)
delay = min(budget_wait, float(self.config.performance.max_sleep))
LOGGER.info("Scan round done in %.1fs, next round in %.0fs", elapsed, delay)
try:
await asyncio.wait_for(stop_event.wait(), timeout=delay)
except asyncio.TimeoutError:
continue
# -- internals ---------------------------------------------------------
def _cache_for(self, organization: str) -> _cache.OrgCache:
"""The cache to scan against, honouring cache.enabled."""
previous = self._caches.get(organization)
if not self.cache_store.enabled:
# Caching off: carry nothing but the organization ID and the scan
# counter across rounds, so every scan re-reads everything.
cache = _cache.OrgCache(organization=organization)
if previous:
cache.organization_id = previous.organization_id
cache.scan_serial = previous.scan_serial
else:
cache = previous or self.cache_store.load(organization)
self._caches[organization] = cache
return cache
def _due_for_full_scan(self, cache: _cache.OrgCache) -> bool:
every = self.config.scan.full_scan_every
if every <= 0:
return False
return cache.scan_serial % every == 0
def _budget_wait(self) -> float:
limit = self.client.rate_limit
if not limit.known:
return 0.0
if limit.remaining > self.config.performance.rate_limit_reserve:
return 0.0
return limit.seconds_until_reset() + 5
def _page_size(
self, size: int, label: str, registry: list[ghclient.AdaptivePageSize]
) -> ghclient.AdaptivePageSize:
page_size = ghclient.AdaptivePageSize(size, self.config.performance.min_page_size, label)
registry.append(page_size)
return page_size
async def _fetch_meta(self, organization: str) -> OrgMeta:
"""One point buys the org ID and the three totals the cache keys on."""
data = await self.client.query(META_QUERY, {"org": organization}, label=f"meta[{organization}]")
org = as_dict(data.get("organization"))
if not org:
raise ghclient.GitHubError(
f"GitHub returned no data for organization '{organization}'. Does it exist, "
"and does the token have access to it?"
)
return {
"id": as_optional_int(org.get("databaseId")),
"members": as_int(as_dict(org.get("membersWithRole")).get("totalCount")),
"teams": as_int(as_dict(org.get("teams")).get("totalCount")),
"repositories": as_int(as_dict(org.get("repositories")).get("totalCount")),
}
async def _fetch_members(
self,
organization: str,
cache: _cache.OrgCache,
total: int,
full_scan: bool,
page_sizes: list[ghclient.AdaptivePageSize],
) -> tuple[list[Member], bool]:
"""All organization members, with numeric ID, name and 2FA status.
GitHub has no "changed since" filter on organization membership, so the
cache is reused only while the member count is unchanged and the cached
copy is younger than cache.member_ttl. That bounds how long a 2FA change
can go unnoticed.
"""
if not full_scan and cache.is_fresh("members", self.config.cache.member_ttl, total):
LOGGER.info(
"Reusing %d cached members for %s (%.0fs old, count unchanged)",
len(cache.members),
organization,
cache.age("members"),
)
return [Member.from_dict(record) for record in cache.members.values()], True
page_size = self._page_size(self.config.performance.member_page_size, "members", page_sizes)
members: list[Member] = []
cursor: str | None = None
while True:
data = await self.client.query(
MEMBERS_QUERY,
{"org": organization, "size": page_size.size, "after": cursor},
label=f"members[{organization}]",
page_size=page_size,
)
connection = _connection(data, "membersWithRole")
for entry in as_list(connection.get("edges")):
edge = as_dict(entry)
node = as_dict(edge.get("node"))
login = as_str(node.get("login"))
if not login: # A membership GitHub would not resolve for us
continue
members.append(
Member(
login=login,
id=as_optional_int(node.get("databaseId")),
name=as_str(node.get("name")) or None,
two_factor_enabled=as_optional_bool(edge.get("hasTwoFactorEnabled")),
role=as_str(edge.get("role"), "member").lower(),
)
)
page = page_info(connection)
if not page.more:
break
cursor = page.cursor
if any(member.two_factor_enabled is None for member in members):
LOGGER.warning(
"GitHub did not report 2FA status for some %s members. The token needs "
"organization owner rights (read:org) to see it.",
organization,
)
cache.members = {member.login: member.to_dict() for member in members}
cache.members_total = len(members)
cache.members_updated_at = time.time()
return members, False
async def _fetch_teams(
self,
organization: str,
cache: _cache.OrgCache,
total: int,
full_scan: bool,
page_sizes: list[ghclient.AdaptivePageSize],
) -> tuple[list[Team], TeamScanStats]:
"""Teams matching the configured patterns, with their members."""
stats = TeamScanStats()
patterns_changed = cache.team_patterns != list(self.config.scan.team_patterns)
if patterns_changed and cache.teams:
LOGGER.info("Team patterns changed since the last scan of %s; re-reading teams", organization)
cache.teams = {}
if not full_scan and not patterns_changed and cache.is_fresh("teams", self.config.cache.team_ttl, total):
LOGGER.info(
"Reusing %d cached teams for %s (%.0fs old, team count unchanged)",
len(cache.teams),
organization,
cache.age("teams"),
)
stats.cached = len(cache.teams)
stats.all_cached = True
return [Team.from_dict(record) for record in cache.teams.values()], stats
matched = await self._fetch_team_list(organization, page_sizes)
# Only re-read the member list of teams whose size changed, whose cached
# copy has aged out, or that we have not seen before. On a settled
# organization that is a handful of teams out of thousands.
now = time.time()
stale: list[TeamMeta] = []
teams: list[Team] = []
for meta in matched:
cached = cache.teams.get(meta["slug"]) if not full_scan else None
if cached and self._team_cache_usable(cached, meta, now):
team = Team.from_dict(cached)
team.name = meta["name"] # Team metadata is free, take the fresh copy
team.id = meta["id"]
team.privacy = meta["privacy"]
teams.append(team)
stats.cached += 1
else:
stale.append(meta)
if stale:
refreshed = await self._fetch_team_members(organization, stale, page_sizes)
teams.extend(refreshed)
stats.refreshed = len(refreshed)
cache.teams = {team.slug: _cache.cached_team(team.to_dict(), now) for team in teams}
# The total tracked here is the organization's total team count rather
# than the matched count: it is the change signal the meta query gives us.
cache.teams_total = total
cache.teams_updated_at = now
cache.team_patterns = list(self.config.scan.team_patterns)
return teams, stats
def _team_cache_usable(self, cached: _cache.CachedTeamRecord, meta: TeamMeta, now: float) -> bool:
"""True if a cached team's member list can be reused as it stands."""
ttl = self.config.cache.team_ttl
if ttl <= 0:
return False
if cached.get("member_count", -1) != meta["member_count"]:
return False
return now - cached.get("refreshed_at", 0.0) <= ttl
async def _fetch_team_list(
self, organization: str, page_sizes: list[ghclient.AdaptivePageSize]
) -> list[TeamMeta]:
"""Every team in the organization, filtered down to the matching ones."""
page_size = self._page_size(self.config.performance.team_page_size, "teams", page_sizes)
matched: list[TeamMeta] = []
seen = 0
cursor: str | None = None
while True:
data = await self.client.query(
TEAMS_QUERY,
{"org": organization, "size": page_size.size, "after": cursor},
label=f"teams[{organization}]",
page_size=page_size,
)
connection = _connection(data, "teams")
for entry in as_list(connection.get("nodes")):
node = as_dict(entry)
if not node:
continue
seen += 1
slug = as_str(node.get("slug"))
name = as_str(node.get("name"))
if not slug or not self.config.scan.matches_team(slug, name):
continue
matched.append(
{
"slug": slug,
"name": name,
"id": as_optional_int(node.get("databaseId")),
"privacy": as_str(node.get("privacy")).lower(),
"member_count": as_int(as_dict(node.get("members")).get("totalCount")),
}
)
page = page_info(connection)
if not page.more:
break
cursor = page.cursor
LOGGER.info(
"%s: %d of %d teams match %s", organization, len(matched), seen, self.config.scan.team_patterns
)
return matched
async def _fetch_team_members(
self,
organization: str,
teams: list[TeamMeta],
page_sizes: list[ghclient.AdaptivePageSize],
) -> list[Team]:
"""Fetch member lists for the given teams, several teams per request."""
page_size = self._page_size(self.config.performance.team_member_page_size, "team members", page_sizes)
results: dict[str, Team] = {
meta["slug"]: Team(
slug=meta["slug"],
name=meta["name"],
id=meta["id"],
privacy=meta["privacy"],
members=[],
member_count=meta["member_count"],
)
for meta in teams
}
# Each entry is a team still needing members, as a slug and a cursor.
pending: list[tuple[str, str | None]] = [(meta["slug"], None) for meta in teams]
batch_size = self.config.performance.team_batch_size
while pending:
batch, pending = pending[:batch_size], pending[batch_size:]
document = build_team_members_query(len(batch))
variables: dict[str, typing.Any] = {"org": organization, "size": page_size.size}
for index, (slug, cursor) in enumerate(batch):
variables[f"s{index}"] = slug
variables[f"c{index}"] = cursor
data = await self.client.query(
document,
variables,
label=f"team-members[{organization}, {len(batch)} teams]",
page_size=page_size,
)
org_data = as_dict(data.get("organization"))
for index, (slug, _cursor) in enumerate(batch):
node = as_dict(org_data.get(f"t{index}"))
if not node: # Team deleted mid-scan, or invisible to this token
LOGGER.warning("%s: team %s returned no data, skipping", organization, slug)
results.pop(slug, None)
continue
connection = as_dict(node.get("members"))
team = results[slug]
for entry in as_list(connection.get("nodes")):
login = as_str(as_dict(entry).get("login"))
if login:
team.members.append(login)
team.member_count = as_int(connection.get("totalCount"), len(team.members))
page = page_info(connection)
if page.more:
# A big team: queue its next page with the other leftovers.
pending.append((slug, page.cursor))
for team in results.values():
team.members = sorted(set(team.members))
return list(results.values())
async def _fetch_repositories(
self,
organization: str,
cache: _cache.OrgCache,
total: int,
full_scan: bool,
page_sizes: list[ghclient.AdaptivePageSize],
) -> tuple[list[Repository], int]:
"""All repositories, most recently updated first.
On an incremental scan the sweep stops as soon as it reaches a repository
GitHub last updated before our previous sweep, and the rest comes from
the cache, since repository metadata cannot change without updatedAt
moving. Deletions leave no trace in that ordering, so they are caught by
the totals from the meta query (a changed count forces a full sweep) and
by the periodic full scan.
"""
incremental = (
not full_scan
and bool(cache.repositories)
and cache.repositories_updated_at > 0
and cache.repositories_total == total
and self.config.cache.repository_ttl > 0
and cache.age("repositories") <= self.config.cache.repository_ttl
)
cutoff = 0.0
if incremental:
cutoff = cache.repositories_updated_at - self.config.cache.incremental_overlap
page_size = self._page_size(self.config.performance.repository_page_size, "repositories", page_sizes)
repositories: dict[str, Repository] = {}
cursor: str | None = None
stopped_early = False
while not stopped_early:
data = await self.client.query(
REPOSITORIES_QUERY,
{"org": organization, "size": page_size.size, "after": cursor},
label=f"repositories[{organization}]",
page_size=page_size,
)
connection = _connection(data, "repositories")
for entry in as_list(connection.get("nodes")):
node = as_dict(entry)
name = as_str(node.get("name"))
if not name:
continue
updated_at = as_str(node.get("updatedAt"))
if incremental and updated_at and ghclient.parse_timestamp(updated_at) < cutoff:
stopped_early = True
break
repositories[name] = Repository(
name=name,
id=as_optional_int(node.get("databaseId")),
visibility=as_str(node.get("visibility"), "public").lower(),
url=as_str(node.get("url"), f"https://github.com/{organization}/{name}"),
archived=bool(node.get("isArchived")),
updated_at=updated_at or None,
)
if stopped_early:
break
page = page_info(connection)
if not page.more:
break
cursor = page.cursor
reused = 0
if incremental:
for name, record in cache.repositories.items():
if name not in repositories:
repositories[name] = Repository.from_dict(record)
reused += 1
LOGGER.info(
"%s: refreshed %d repositories, reused %d unchanged from cache",
organization,
len(repositories) - reused,
reused,
)
cache.repositories = {name: repo.to_dict() for name, repo in repositories.items()}
cache.repositories_total = total
cache.repositories_updated_at = time.time()
if len(repositories) != total:
# Either the organization changed under us mid-scan, or an
# incremental sweep has drifted. Either way, scan it all next time.
LOGGER.warning(
"%s: found %d repositories but GitHub reports %d; forcing a full sweep next round",
organization,
len(repositories),
total,
)
cache.repositories_total = -1
return list(repositories.values()), reused
def _connection(data: GraphQLData, name: str) -> GraphQLData:
"""Pull one connection out of an organization query response."""
return as_dict(as_dict(data.get("organization")).get(name))
async def scan_once(
config: _config.Configuration | str,
organizations: typing.Sequence[str] | None = None,
*,
full: bool = False,
write: bool = True,
) -> dict[str, OrgSnapshot]:
"""Run a single scan of every configured organization.
config may be a Configuration or the path to a YAML config file. This is the
simplest way for Boxer to call the scanner from one of its own background
tasks:
snapshots = await orgscanner.scan_once("orgscanner.yaml")
"""
configuration = config if isinstance(config, _config.Configuration) else _config.load_config(config)
async with OrgScanner(configuration) as scanner:
return await scanner.scan_all(organizations, full=full, write=write)
async def run_forever(
config: _config.Configuration | str,
stop_event: asyncio.Event | None = None,
) -> None:
"""Run the scan loop until stop_event is set."""
configuration = config if isinstance(config, _config.Configuration) else _config.load_config(config)
async with OrgScanner(configuration) as scanner:
await scanner.run_forever(stop_event)