Skip to content
Draft
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
521 changes: 518 additions & 3 deletions contrib/grafana-dashboard.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions infra/common.nix
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ in

systemd.tmpfiles.rules = [
"d ${config.services.nix-security-tracker.settings.METRICS_TEXTFILE_DIR} 2750 nix-security-tracker ${config.services.prometheus.exporters.node.user} -"
"d /var/lib/nix-security-tracker/prometheus-multiproc 0750 nix-security-tracker nix-security-tracker -"
];

services.prometheus.exporters.postgres = {
Expand Down
69 changes: 67 additions & 2 deletions nix/configuration.nix
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,27 @@ in
type = types.int;
default = 2;
};
metricsWorkerPort = mkOption {
description = ''
TCP port for the main listen worker Prometheus /metrics endpoint (matching).
'';
type = types.port;
default = 9252;
};
metricsEvaluatorPort = mkOption {
description = ''
TCP port for the evaluator multiprocess Prometheus /metrics sidecar.
'';
type = types.port;
default = 9253;
};
};

config = mkIf cfg.enable {
networking.firewall.allowedTCPPorts = [
cfg.metricsWorkerPort
cfg.metricsEvaluatorPort
];
environment.systemPackages = [ wstExternalManageScript ];
services = {
# TODO(@fricklerhandwerk): move all configuration over to pydantic-settings
Expand Down Expand Up @@ -332,7 +350,14 @@ in
];
wantedBy = [ "multi-user.target" ];

environment = {
PROMETHEUS_MULTIPROC_DIR = "/var/lib/nix-security-tracker/prometheus-multiproc";
};

script = ''
mkdir -p "$PROMETHEUS_MULTIPROC_DIR"
# prometheus_client multiprocess mode requires an empty directory at start.
find "$PROMETHEUS_MULTIPROC_DIR" -mindepth 1 -delete
# Before starting, crash all the in-progress evaluations.
# This will prevent them from being stalled forever, since workers would not pick up evaluations marked as in-progress.
wst-manage crash_all_evaluations
Expand All @@ -343,6 +368,31 @@ in
'';
};

nix-security-tracker-evaluator-metrics = {
description = "Web security tracker - evaluator Prometheus metrics sidecar";
after = [
"network.target"
"nix-security-tracker-evaluator.service"
];
requires = [ "nix-security-tracker-evaluator.service" ];
wantedBy = [ "multi-user.target" ];

environment = {
PROMETHEUS_MULTIPROC_DIR = "/var/lib/nix-security-tracker/prometheus-multiproc";
DJANGO_SETTINGS = builtins.toJSON (
cfg.settings
// {
METRICS_HTTP_PORT = cfg.metricsEvaluatorPort;
}
);
};

script = ''
mkdir -p "$PROMETHEUS_MULTIPROC_DIR"
wst-manage serve_worker_metrics

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My hunch is this could be an ad hoc more-or-less-inline Python HTTP server here, which we put behind the proxy. Seems kind of wrong to weave this into the application code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need the sidecar because evaluator workers use multiprocess mode: each process must serve MultiProcessCollector. Putting that in wst-manage keeps Django settings/METRICS_HTTP_PORT the same as the worker path. An inline Python one-lineer in nix is possible, but it would still need the same prometheus_client + multiproc wiring, and would drift from how the worker exposes metrics. Happy to extract a thinner entrypoint if you want it out of Django management commands , but "serve aggregated multiproc metrics" still has to live somewhere next to the app that writes those files.

'';
};

nix-security-tracker-caching = {
description = "Web security tracker - cache regeneration";
after = [
Expand Down Expand Up @@ -380,6 +430,13 @@ in
];
wantedBy = [ "multi-user.target" ];

environment.DJANGO_SETTINGS = builtins.toJSON (
cfg.settings
// {
METRICS_HTTP_PORT = cfg.metricsWorkerPort;
}
);

script = ''
wst-manage listen --recover \
--channels \
Expand Down Expand Up @@ -425,7 +482,11 @@ in
"postgresql.service"
"nix-security-tracker-worker.service"
];
serviceConfig.Type = "oneshot";
serviceConfig = {
Type = "oneshot";
# Make performance metrics file, produced as a side effect, readable by Prometheus node exporter
UMask = "0027";
};

script = ''
wst-manage ingest_delta_cve "$(date --date='yesterday' --iso)" ${
Expand All @@ -450,7 +511,11 @@ in
];
wantedBy = [ "multi-user.target" ];

serviceConfig.Type = "oneshot";
serviceConfig = {
Type = "oneshot";
# Make performance metrics file, produced as a side effect, readable by Prometheus node exporter
UMask = "0027";
};
script = ''
wst-manage garbage_collect
'';
Expand Down
6 changes: 6 additions & 0 deletions src/project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ class DjangoSettings(BaseModel):
""",
default=None,
)
METRICS_HTTP_PORT: int | None = Field(
description="""
If set, expose prometheus_client /metrics on this TCP port (listen workers).
""",
default=None,
)
CHANNEL_MONITORING_URL: HttpUrl = Field(
description="""
URL from which to fetch the current channel structure.
Expand Down
8 changes: 8 additions & 0 deletions src/shared/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ class SharedConfig(AppConfig):
name = "shared"

def ready(self) -> None:
import sys

import shared.listeners # noqa

# Expose /metrics only in pgpubsub listen --worker processes (not web/oneshots).
if settings.METRICS_HTTP_PORT is not None and "--worker" in sys.argv:
from shared.metrics import start_worker_metrics_server

start_worker_metrics_server()

# TODO: run this as a separate service, as this is almost exclusively a deployment concern
if settings.SYNC_GITHUB_STATE_AT_STARTUP:
from shared.auth.github_state import GithubState
Expand Down
97 changes: 54 additions & 43 deletions src/shared/listeners/automatic_linkage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# ============================================================================

import logging
import time
from dataclasses import dataclass, field

import pgpubsub
Expand All @@ -24,6 +25,7 @@
)

from shared.channels import ContainerChannel
from shared.metrics import observe_matching
from shared.models.cve import Container, Cpe
from shared.models.linkage import (
CVEDerivationClusterProposal,
Expand Down Expand Up @@ -252,52 +254,61 @@ def produce_linkage_candidates(


def build_new_links(container: Container) -> bool:
if container.cve.triaged:
logger.info(
"Container received for '%s', but already triaged, skipping linkage.",
container.cve,
)
return False

if CVEDerivationClusterProposal.objects.filter(
cve=container.cve,
algorithm_version=CVEDerivationClusterProposal.CURRENT_ALGORITHM_VERSION,
).exists():
logger.info("Suggestion already exists for '%s', skipping", container.cve)
return False

outcome = resolve_linkage_candidates(container)

proposal = CVEDerivationClusterProposal.objects.create(
cve=container.cve,
status=(
CVEDerivationClusterProposal.Status.REJECTED
start = time.time()
candidates = 0
try:
if container.cve.triaged:
logger.info(
"Container received for '%s', but already triaged, skipping linkage.",
container.cve,
)
return False

if CVEDerivationClusterProposal.objects.filter(
cve=container.cve,
algorithm_version=CVEDerivationClusterProposal.CURRENT_ALGORITHM_VERSION,
).exists():
logger.info("Suggestion already exists for '%s', skipping", container.cve)
return False

outcome = resolve_linkage_candidates(container)
if outcome.rejection is not None and outcome.rejection.match_count:
candidates = outcome.rejection.match_count
elif outcome.derivations is not None:
candidates = outcome.derivations.count()

proposal = CVEDerivationClusterProposal.objects.create(
cve=container.cve,
status=(
CVEDerivationClusterProposal.Status.REJECTED
if outcome.rejection
else CVEDerivationClusterProposal.Status.PENDING
),
rejection_reason=outcome.rejection.reason if outcome.rejection else None,
rejection_match_count=outcome.rejection.match_count or None
if outcome.rejection
else CVEDerivationClusterProposal.Status.PENDING
),
rejection_reason=outcome.rejection.reason if outcome.rejection else None,
rejection_match_count=outcome.rejection.match_count or None
if outcome.rejection
else None,
rejection_max_matches_limit=outcome.rejection.max_matches_limit or None
if outcome.rejection
else None,
algorithm_version=CVEDerivationClusterProposal.CURRENT_ALGORITHM_VERSION,
)

if outcome.derivations:
links = build_derivation_links(proposal, outcome.derivations)
DerivationClusterProposalLink.objects.bulk_create(links)
pkg_links = build_package_links(proposal, outcome.derivations)
PackageClusterProposalLink.objects.bulk_create(pkg_links)
logger.info(
"Matching suggestion for '%s': %d derivations, %d packages found.",
container.cve,
len(links),
len(pkg_links),
else None,
rejection_max_matches_limit=outcome.rejection.max_matches_limit or None
if outcome.rejection
else None,
algorithm_version=CVEDerivationClusterProposal.CURRENT_ALGORITHM_VERSION,
)

return True
if outcome.derivations:
links = build_derivation_links(proposal, outcome.derivations)
DerivationClusterProposalLink.objects.bulk_create(links)
pkg_links = build_package_links(proposal, outcome.derivations)
PackageClusterProposalLink.objects.bulk_create(pkg_links)
logger.info(
"Matching suggestion for '%s': %d derivations, %d packages found.",
container.cve,
len(links),
len(pkg_links),
)

return True
finally:
observe_matching(time.time() - start, candidates)


def refresh_suggestion_derivation_links(
Expand Down
2 changes: 2 additions & 0 deletions src/shared/listeners/nix_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
parse_evaluation_result,
)
from shared.git import GitRepo
from shared.metrics import observe_eval_batch_ingest
from shared.models import NixDerivation, NixEvaluation

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -117,6 +118,7 @@ async def realtime_batch_process_attributes(
drvs = await sync_to_async(ingester.ingest)()

elapsed = time.time() - start
observe_eval_batch_ingest(elapsed, len(drvs))
logger.info(
"%d attributes were ingested in %f seconds (%s, %s)",
len(drvs),
Expand Down
Loading