another refactor partly done
Some checks failed
Test / tests (push) Failing after 1m51s
/ OpenTofu (push) Successful in 13s

This commit is contained in:
plasmagoat 2025-07-29 02:18:19 +02:00
parent 3362c47211
commit a955528e44
31 changed files with 3790 additions and 1930 deletions

View file

@ -0,0 +1,55 @@
{lib}: let
inherit (lib) flatten mapAttrs attrValues filterAttrs mapAttrsToList filter groupBy length unique attrByPath splitString;
# Generic function to aggregate any attribute across nodes
aggregateFromNodes = {
nodes,
attributePath, # e.g. "homelab.monitoring.metrics" or "homelab.backups.jobs"
enhancer ? null, # optional function to enhance each item with node context
}: let
# Extract the attribute from each node using the path
getNestedAttr = path: config: let
pathList = splitString "." path;
in
attrByPath pathList [] config;
# Get all items from all nodes
allItems = flatten (mapAttrsToList
(nodeName: nodeConfig: let
items = getNestedAttr attributePath nodeConfig.config;
baseEnhancer = item:
item
// {
_nodeName = nodeName;
_nodeConfig = nodeConfig;
_nodeAddress = nodeConfig.config.networking.hostName or nodeName;
};
finalEnhancer =
if enhancer != null
then (item: enhancer (baseEnhancer item))
else baseEnhancer;
in
map finalEnhancer items)
nodes);
in {
# Raw aggregated data
all = allItems;
# Common grouping patterns
byNode = groupBy (item: item._nodeName) allItems;
byType = groupBy (item: item.type or "unknown") allItems;
byService = groupBy (item: item.service or "unknown") allItems;
# Utility functions for filtering
filterBy = predicate: filter predicate allItems;
ofType = type: filter (item: (item.type or "") == type) allItems;
ofNode = nodeName: filter (item: item._nodeName == nodeName) allItems;
enabled = filter (item: item.enabled or true) allItems;
# Counting utilities
count = length allItems;
countBy = fn: mapAttrs (key: items: length items) (groupBy fn allItems);
};
in {
inherit aggregateFromNodes;
}

View file

@ -0,0 +1,87 @@
serviceName: {
config,
lib,
...
}:
with lib; let
cfg = config.homelab.services.${serviceName};
homelabCfg = config.homelab;
in {
options.homelab.services.${serviceName}.logging = {
enable = mkEnableOption "logging for ${serviceName}";
files = mkOption {
type = types.listOf types.str;
default = [];
};
parsing = {
regex = mkOption {
type = types.nullOr types.str;
default = null;
};
extractFields = mkOption {
type = types.listOf types.str;
default = [];
};
};
multiline = mkOption {
type = types.nullOr (types.submodule {
options = {
firstLineRegex = mkOption {type = types.str;};
maxWaitTime = mkOption {
type = types.str;
default = "3s";
};
};
});
default = null;
};
extraLabels = mkOption {
type = types.attrsOf types.str;
default = {};
};
extraSources = mkOption {
type = types.listOf types.attrs;
default = [];
};
};
config = mkIf (cfg.enable && cfg.logging.enable) {
homelab.logging.sources =
[
{
name = "${serviceName}-logs";
type = "file";
files = {
paths = cfg.logging.files;
multiline = cfg.logging.multiline;
};
labels =
cfg.logging.extraLabels
// {
service = serviceName;
node = homelabCfg.hostname;
environment = homelabCfg.environment;
};
pipelineStages =
mkIf (cfg.logging.parsing.regex != null) [
{
regex.expression = cfg.logging.parsing.regex;
}
]
++ [
{
labels = listToAttrs (map (field: nameValuePair field null) cfg.logging.parsing.extractFields);
}
];
enabled = true;
}
]
++ cfg.logging.extraSources;
};
}

View file

@ -0,0 +1,108 @@
serviceName: {
config,
lib,
...
}:
with lib; let
cfg = config.homelab.services.${serviceName};
homelabCfg = config.homelab;
in {
# Define the service-specific monitoring options
options.homelab.services.${serviceName}.monitoring = {
enable = mkEnableOption "monitoring for ${serviceName}";
metrics = {
enable = mkOption {
type = types.bool;
default = true;
};
path = mkOption {
type = types.str;
default = "/metrics";
};
extraEndpoints = mkOption {
type = types.listOf types.attrs;
default = [];
};
};
healthCheck = {
enable = mkOption {
type = types.bool;
default = true;
};
path = mkOption {
type = types.str;
default = "/health";
};
conditions = mkOption {
type = types.listOf types.str;
default = ["[STATUS] == 200"];
};
extraChecks = mkOption {
type = types.listOf types.attrs;
default = [];
};
};
extraLabels = mkOption {
type = types.attrsOf types.str;
default = {};
};
};
# Generate the homelab config automatically when service is enabled
config = mkIf (cfg.enable && cfg.monitoring.enable) {
homelab.monitoring = {
metrics =
[
{
name = "${serviceName}-main";
host = homelabCfg.hostname;
port = cfg.port;
path = cfg.monitoring.metrics.path;
jobName = serviceName;
scrapeInterval = "30s";
labels =
cfg.monitoring.extraLabels
// {
service = serviceName;
node = homelabCfg.hostname;
environment = homelabCfg.environment;
};
}
]
++ cfg.monitoring.metrics.extraEndpoints;
healthChecks =
[
{
name = "${serviceName}-health";
host = homelabCfg.hostname;
port = cfg.port;
path = cfg.monitoring.healthCheck.path;
protocol = "http";
method = "GET";
interval = "30s";
timeout = "10s";
conditions = cfg.monitoring.healthCheck.conditions;
group = "services";
labels =
cfg.monitoring.extraLabels
// {
service = serviceName;
node = homelabCfg.hostname;
environment = homelabCfg.environment;
};
enabled = true;
}
]
++ cfg.monitoring.healthCheck.extraChecks;
};
};
}

View file

@ -0,0 +1,64 @@
serviceName: {
config,
lib,
...
}:
with lib; let
cfg = config.homelab.services.${serviceName};
homelabCfg = config.homelab;
in {
options.homelab.services.${serviceName}.proxy = {
enable = mkEnableOption "reverse proxy for ${serviceName}";
subdomain = mkOption {
type = types.str;
default = serviceName;
};
enableAuth = mkOption {
type = types.bool;
default = false;
};
additionalSubdomains = mkOption {
type = types.listOf (types.submodule {
options = {
subdomain = mkOption {type = types.str;};
port = mkOption {type = types.port;};
path = mkOption {
type = types.str;
default = "/";
};
enableAuth = mkOption {
type = types.bool;
default = false;
};
};
});
default = [];
};
};
config = mkIf (cfg.enable && cfg.proxy.enable) {
homelab.reverseProxy.entries =
[
{
subdomain = cfg.proxy.subdomain;
host = homelabCfg.hostname;
port = cfg.port;
path = "/";
enableAuth = cfg.proxy.enableAuth;
enableSSL = true;
}
]
++ map (sub: {
subdomain = sub.subdomain;
host = homelabCfg.hostname;
port = sub.port;
path = sub.path;
enableAuth = sub.enableAuth;
enableSSL = true;
})
cfg.proxy.additionalSubdomains;
};
}

View file

@ -1,226 +0,0 @@
{lib}: let
inherit (lib) flatten mapAttrs mapAttrsToList filter groupBy length unique attrByPath splitString;
# Generic function to aggregate any attribute across nodes
aggregateFromNodes = {
nodes,
attributePath, # e.g. "homelab.monitoring.endpoints" or "homelab.backups.jobs"
enhancer ? null, # optional function to enhance each item with node context
}: let
# Extract the attribute from each node using the path
getNestedAttr = path: config: let
pathList = splitString "." path;
in
attrByPath pathList [] config;
# Get all items from all nodes
allItems = flatten (mapAttrsToList
(nodeName: nodeConfig: let
items = getNestedAttr attributePath nodeConfig.config;
baseEnhancer = item:
item
// {
_nodeName = nodeName;
_nodeConfig = nodeConfig;
_nodeAddress = nodeConfig.config.networking.hostName or nodeName;
};
finalEnhancer =
if enhancer != null
then (item: enhancer (baseEnhancer item))
else baseEnhancer;
in
map finalEnhancer items)
nodes);
in {
# Raw aggregated data
all = allItems;
# Common grouping patterns
byNode = groupBy (item: item._nodeName) allItems;
byType = groupBy (item: item.type or "unknown") allItems;
byService = groupBy (item: item.service or "unknown") allItems;
# Utility functions for filtering
filterBy = predicate: filter predicate allItems;
ofType = type: filter (item: (item.type or "") == type) allItems;
count = length allItems;
countBy = fn: mapAttrs (key: items: length items) (groupBy fn allItems);
};
# Specialized aggregators for common use cases
aggregators = {
monitoring = nodes: let
# Aggregate metrics endpoints
metricsAgg = aggregateFromNodes {
inherit nodes;
attributePath = "homelab.monitoring.metrics";
enhancer = endpoint:
endpoint
// {
_fullAddress = "${endpoint.host or endpoint._nodeAddress}:${toString endpoint.port}";
_metricsUrl = "http://${endpoint.host or endpoint._nodeAddress}:${toString endpoint.port}${endpoint.path or "/metrics"}";
_type = "metrics";
};
};
# Aggregate health checks
healthChecksAgg = aggregateFromNodes {
inherit nodes;
attributePath = "homelab.monitoring.healthChecks";
enhancer = check: let
# Compute the actual host and URL
actualHost =
if check.useExternalDomain or false
then "${check.subdomain}.${check._nodeConfig.config.homelab.externalDomain or "example.com"}"
else check.host or check._nodeAddress;
portPart =
if check.port != null
then ":${toString check.port}"
else "";
url = "${check.protocol or "http"}://${actualHost}${portPart}${check.path or "/"}";
in
check
// {
_actualHost = actualHost;
_url = url;
_type = "health-check";
# Merge default labels with node context
labels =
(check.labels or {})
// {
node = check._nodeName;
environment = check._nodeConfig.config.homelab.environment or "unknown";
};
};
};
in
metricsAgg
// healthChecksAgg
// {
# Metrics-specific aggregations
allMetrics = metricsAgg.all;
metricsByNode = metricsAgg.byNode;
metricsByJobName = groupBy (m: m.jobName or "unknown") metricsAgg.all;
# Health checks-specific aggregations
allHealthChecks = healthChecksAgg.all;
healthChecksByNode = healthChecksAgg.byNode;
healthChecksByGroup = groupBy (hc: hc.group or "default") healthChecksAgg.all;
healthChecksByProtocol = groupBy (hc: hc.protocol or "http") healthChecksAgg.all;
# Filtered health checks
externalHealthChecks = filter (hc: hc.useExternalDomain or false) healthChecksAgg.all;
internalHealthChecks = filter (hc: !(hc.useExternalDomain or false)) healthChecksAgg.all;
enabledHealthChecks = filter (hc: hc.enabled or true) healthChecksAgg.all;
# Summary statistics
summary = {
totalMetrics = length metricsAgg.all;
totalHealthChecks = length healthChecksAgg.all;
healthChecksByGroup =
mapAttrs (group: checks: length checks)
(groupBy (hc: hc.group or "default") healthChecksAgg.all);
healthChecksByProtocol =
mapAttrs (protocol: checks: length checks)
(groupBy (hc: hc.protocol or "http") healthChecksAgg.all);
externalChecksCount = length (filter (hc: hc.useExternalDomain or false) healthChecksAgg.all);
internalChecksCount = length (filter (hc: !(hc.useExternalDomain or false)) healthChecksAgg.all);
};
};
# Promtail log configurations
# logs = nodes:
# aggregateFromNodes {
# inherit nodes;
# attributePath = "homelab.logging.sources";
# enhancer = logSource:
# logSource
# // {
# # Add log-specific computed fields
# _logPath = logSource.path or "/var/log/${logSource.service}.log";
# _labels =
# (logSource.labels or {})
# // {
# node = logSource._nodeName;
# service = logSource.service or "unknown";
# };
# };
# };
# Reverse proxy configurations
reverseProxy = nodes:
aggregateFromNodes {
inherit nodes;
attributePath = "homelab.reverseProxy.entries";
enhancer = entry:
entry
// {
# Add proxy-specific computed fields
_upstream = "http://${entry.host or entry._nodeAddress}:${toString entry.port}";
_fqdn = "${entry.subdomain or entry.service}.${entry.domain or "local"}";
};
};
# Backup jobs with enhanced aggregation
backups = nodes: let
baseAgg = aggregateFromNodes {
inherit nodes;
attributePath = "homelab.backups.jobs";
enhancer = backup:
backup
// {
_sourceNode = backup._nodeName;
_backupId = "${backup._nodeName}-${backup.name}";
_jobFqdn = "${backup.name}.${backup._nodeName}";
};
};
# Get all unique backends across all nodes
allBackends = let
allBackendConfigs =
mapAttrsToList
(nodeName: nodeConfig:
attrByPath ["homelab" "backups" "backends"] {} nodeConfig.config)
nodes;
enabledBackends = flatten (map (backends:
filter (name: backends.${name} != null) (lib.attrNames backends))
allBackendConfigs);
in
unique enabledBackends;
in
baseAgg
// {
# Backup-specific aggregations
byBackend = groupBy (job: job.backend) baseAgg.all;
allBackends = allBackends;
# Enhanced summary
summary = {
totalJobs = length baseAgg.all;
jobsByBackend =
mapAttrs (backend: jobs: length jobs)
(groupBy (job: job.backend) baseAgg.all);
jobsByNode = baseAgg.countBy (job: job._nodeName);
availableBackends = allBackends;
backendsInUse = unique (map (job: job.backend) baseAgg.all);
};
};
};
in {
inherit aggregateFromNodes aggregators;
# Convenience function to create a module that provides global aggregations
mkGlobalModule = attributeName: aggregatorFn: {
lib,
nodes,
...
}: {
options.homelab.global.${attributeName} = lib.mkOption {
type = lib.types.attrs;
readOnly = true;
description = "Globally aggregated ${attributeName} from all nodes";
};
config.homelab.global.${attributeName} = aggregatorFn nodes;
};
}

View file

@ -1,295 +0,0 @@
# Standard service interface for homelab services
# This provides a consistent contract that all services should follow
{lib}: let
inherit (lib) mkOption mkEnableOption types;
# Define the standard service interface
mkServiceInterface = {
serviceName,
defaultPort ? null,
defaultSubdomain ? serviceName,
defaultDescription ? "Homelab ${serviceName} service",
monitoringPath ? "/metrics",
healthCheckPath ? "/health",
healthCheckConditions ? ["[STATUS] == 200"],
# Custom options that the service wants to expose
serviceOptions ? {},
}:
{
# Standard interface options that all services must have
enable = mkEnableOption defaultDescription;
port = mkOption {
type = types.port;
default =
if defaultPort != null
then defaultPort
else throw "Service ${serviceName} must specify a default port";
description = "Port for ${serviceName} service";
};
openFirewall = mkOption {
type = types.bool;
default = true;
description = "Whether to automatically open firewall ports";
};
proxy = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable reverse proxy for this service";
};
subdomain = mkOption {
type = types.str;
default = defaultSubdomain;
description = "Subdomain for reverse proxy (${defaultSubdomain}.yourdomain.com)";
};
enableAuth = mkOption {
type = types.bool;
default = false;
description = "Enable authentication for reverse proxy";
};
enableSSL = mkOption {
type = types.bool;
default = true;
description = "Enable SSL for reverse proxy";
};
};
monitoring = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable monitoring (metrics and health checks)";
};
metricsPath = mkOption {
type = types.str;
default = monitoringPath;
description = "Path for metrics endpoint";
};
jobName = mkOption {
type = types.str;
default = serviceName;
description = "Prometheus job name";
};
scrapeInterval = mkOption {
type = types.str;
default = "30s";
description = "Prometheus scrape interval";
};
healthCheck = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable health check monitoring";
};
path = mkOption {
type = types.str;
default = healthCheckPath;
description = "Path for health check endpoint";
};
interval = mkOption {
type = types.str;
default = "30s";
description = "Health check interval";
};
timeout = mkOption {
type = types.str;
default = "10s";
description = "Health check timeout";
};
conditions = mkOption {
type = types.listOf types.str;
default = healthCheckConditions;
description = "Health check conditions";
};
group = mkOption {
type = types.str;
default = "services";
description = "Health check group name";
};
};
extraLabels = mkOption {
type = types.attrsOf types.str;
default = {};
description = "Additional labels for monitoring";
};
};
description = mkOption {
type = types.str;
default = defaultDescription;
description = "Service description";
};
extraOptions = mkOption {
type = types.attrs;
default = {};
description = "Additional service-specific configuration options";
};
# Merge in service-specific options
}
// serviceOptions;
# Helper function to implement the standard service behavior
mkServiceConfig = {
config,
cfg,
homelabCfg,
serviceName,
# Function that returns the actual service configuration
serviceConfig,
# Optional: custom monitoring labels
extraMonitoringLabels ? {},
# Optional: custom health check configuration
customHealthChecks ? [],
# Optional: custom reverse proxy configuration
customProxyConfig ? {},
}: let
# Standard monitoring labels
standardLabels =
{
service = serviceName;
component = "main";
instance = "${homelabCfg.hostname}.${homelabCfg.domain}";
}
// extraMonitoringLabels // cfg.monitoring.extraLabels;
# Standard reverse proxy entry
standardProxyEntry =
{
subdomain = cfg.proxy.subdomain;
host = homelabCfg.hostname;
port = cfg.port;
enableAuth = cfg.proxy.enableAuth;
enableSSL = cfg.proxy.enableSSL;
}
// customProxyConfig;
# Standard metrics configuration
standardMetrics = lib.optional cfg.monitoring.enable {
name = "${serviceName}-metrics";
port = cfg.port;
path = cfg.monitoring.metricsPath;
jobName = cfg.monitoring.jobName;
scrapeInterval = cfg.monitoring.scrapeInterval;
labels = standardLabels;
};
# Standard health check configuration
standardHealthCheck = lib.optional (cfg.monitoring.enable && cfg.monitoring.healthCheck.enable) {
name = "${serviceName}-health";
port = cfg.port;
path = cfg.monitoring.healthCheck.path;
interval = cfg.monitoring.healthCheck.interval;
timeout = cfg.monitoring.healthCheck.timeout;
conditions = cfg.monitoring.healthCheck.conditions;
group = cfg.monitoring.healthCheck.group;
labels = standardLabels;
};
# Merge service config with standard behaviors
baseConfig = lib.mkMerge [
# Service-specific configuration
serviceConfig
# Standard firewall configuration
(lib.mkIf cfg.openFirewall {
networking.firewall.allowedTCPPorts = [cfg.port];
})
# Standard monitoring configuration
(lib.mkIf cfg.monitoring.enable {
homelab.monitoring.metrics = standardMetrics;
homelab.monitoring.healthChecks = standardHealthCheck ++ customHealthChecks;
})
# Standard reverse proxy configuration
(lib.mkIf cfg.proxy.enable {
homelab.reverseProxy.entries = [standardProxyEntry];
})
];
in
lib.mkIf cfg.enable baseConfig;
# Validation helper to ensure required options are set
validateServiceConfig = cfg: serviceName: [
# Validate that if proxy is enabled, subdomain is set
(lib.mkIf (cfg.proxy.enable && cfg.proxy.subdomain == "")
(throw "Service ${serviceName}: proxy.subdomain is required when proxy.enable is true"))
# Validate that if monitoring is enabled, required paths are set
(lib.mkIf (cfg.monitoring.enable && cfg.monitoring.metricsPath == "")
(throw "Service ${serviceName}: monitoring.metricsPath cannot be empty when monitoring is enabled"))
];
in {
inherit mkServiceInterface mkServiceConfig validateServiceConfig;
# Common service option patterns
commonOptions = {
# Log level option
logLevel = mkOption {
type = types.enum ["debug" "info" "warn" "error"];
default = "info";
description = "Log level";
};
# Environment file option (for secrets)
environmentFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Environment file for secrets";
};
# External URL option
externalUrl = serviceName: homelabCfg:
mkOption {
type = types.str;
default = "https://${serviceName}.${homelabCfg.externalDomain}";
description = "External URL for ${serviceName}";
};
};
# Helper for creating service modules with the interface
mkServiceModule = {
serviceName,
defaultPort,
defaultSubdomain ? serviceName,
serviceOptions ? {},
...
} @ args: {
config,
lib,
...
}: let
cfg = config.homelab.services.${serviceName};
homelabCfg = config.homelab;
serviceInterface = mkServiceInterface {
inherit serviceName defaultPort defaultSubdomain serviceOptions;
};
in {
options.homelab.services.${serviceName} = serviceInterface;
config = mkServiceConfig {
inherit config cfg homelabCfg serviceName;
# Service implementor must provide this function
serviceConfig = args.serviceConfig or (throw "mkServiceModule requires serviceConfig function");
};
};
}

View file

@ -0,0 +1,163 @@
{
config,
lib,
nodes,
...
}:
with lib; let
cfg = config.homelab.backups;
homelabCfg = config.homelab;
hasNodes = length (attrNames nodes) > 0;
# Get all defined backend names dynamically
backendNames = attrNames cfg.backends or {};
backupJobType = types.submodule {
options = {
name = mkOption {
type = types.str;
description = "Name of the backup job";
};
backend = mkOption {
type = types.enum backendNames;
description = "Backend to use for this backup job";
};
backendOptions = mkOption {
type = types.attrs;
default = {};
description = "Backend-specific options to override or extend the backend configuration";
};
labels = mkOption {
type = types.attrsOf types.str;
default = {};
description = "Additional labels for this backup job";
};
};
};
# Local aggregation
localAggregation = {
allJobs = cfg.jobs;
allBackends = backendNames;
};
# Global aggregation
globalAggregation = let
baseAgg = import ../aggregators/base.nix {inherit lib;};
jobsAgg = baseAgg.aggregateFromNodes {
inherit nodes;
attributePath = "homelab.backups.allJobs";
enhancer = job:
job
// {
_sourceNode = job._nodeName;
_backupId = "${job._nodeName}-${job.name}";
_jobFqdn = "${job.name}.${job._nodeName}";
};
};
# Get all backends from all nodes
allBackendsFromNodes = let
backendConfigs =
mapAttrsToList (
nodeName: nodeConfig:
attrByPath ["homelab" "backups" "backends"] {} nodeConfig.config
)
nodes;
enabledBackends = flatten (map (
backends:
filter (name: backends.${name} != null) (attrNames backends)
)
backendConfigs);
in
unique enabledBackends;
in {
allJobs = jobsAgg.all;
allBackends = allBackendsFromNodes;
jobsByBackend = groupBy (j: j.backend) jobsAgg.all;
summary = {
total = length jobsAgg.all;
byBackend = jobsAgg.countBy (j: j.backend);
byNode = jobsAgg.countBy (j: j._nodeName);
uniqueBackends = unique (map (j: j.backend) jobsAgg.all);
};
};
in {
imports = [
../../backup/restic.nix
# ./backup/borgbackup.nix
];
options.homelab.backups = {
enable = mkEnableOption "backup system";
jobs = mkOption {
type = types.listOf backupJobType;
default = [];
description = "Backup jobs to execute on this system";
};
# Backend configurations (like your existing setup)
# backends = mkOption {
# type = types.attrs;
# default = {};
# description = "Backup backend configurations";
# };
defaultLabels = mkOption {
type = types.attrsOf types.str;
default = {
hostname = homelabCfg.hostname;
environment = homelabCfg.environment;
location = homelabCfg.location;
};
description = "Default labels applied to all backup jobs";
};
monitoring = mkOption {
type = types.bool;
default = true;
description = "Enable backup monitoring and metrics";
};
# Always exposed aggregated data
allJobs = mkOption {
type = types.listOf types.attrs;
default = [];
readOnly = true;
};
allBackends = mkOption {
type = types.listOf types.str;
default = [];
readOnly = true;
};
global = mkOption {
type = types.attrs;
default = {};
readOnly = true;
};
};
config = mkIf cfg.enable {
# Validate that all job backends exist
assertions = [
{
assertion = all (job: cfg.backends.${job.backend} != null) cfg.jobs;
message = "All backup jobs must reference backends that are defined and not null in homelab.backups.backends";
}
];
# Always expose both local and global
homelab.backups = {
allJobs = localAggregation.allJobs;
allBackends = localAggregation.allBackends;
global =
if hasNodes
then globalAggregation
else {};
};
};
}

View file

@ -0,0 +1,209 @@
{
config,
lib,
nodes,
...
}:
with lib; let
cfg = config.homelab.logging;
homelabCfg = config.homelab;
hasNodes = length (attrNames nodes) > 0;
# Local aggregation
localAggregation = {
allSources =
cfg.sources
++ (optional cfg.promtail.enable {
name = "system-journal";
type = "journal";
journal.path = "/var/log/journal";
labels =
cfg.defaultLabels
// {
component = "system";
log_source = "journald";
};
enabled = true;
});
};
# Global aggregation
globalAggregation = let
baseAgg = import ../aggregators/base.nix {inherit lib;};
sourcesAgg = baseAgg.aggregateFromNodes {
inherit nodes;
attributePath = "homelab.logging.allSources";
enhancer = source:
source
// {
_sourceNode = source._nodeName;
_logId = "${source._nodeName}-${source.name}";
};
};
in {
allSources = sourcesAgg.all;
sourcesByType = groupBy (s: s.type) sourcesAgg.all;
summary = {
total = length sourcesAgg.all;
byType = sourcesAgg.countBy (s: s.type);
byNode = sourcesAgg.countBy (s: s._nodeName);
};
};
in {
options.homelab.logging = {
enable = mkEnableOption "logging system";
promtail = {
enable = mkOption {
type = types.bool;
default = true;
};
port = mkOption {
type = types.port;
default = 9080;
};
clients = mkOption {
type = types.listOf (types.submodule {
options = {
url = mkOption {type = types.str;};
tenant_id = mkOption {
type = types.nullOr types.str;
default = null;
};
};
});
default = [{url = "http://monitor.${homelabCfg.domain}:3100/loki/api/v1/push";}];
};
};
sources = mkOption {
type = types.listOf (types.submodule {
options = {
name = mkOption {type = types.str;};
type = mkOption {
type = types.enum ["journal" "file" "syslog" "docker"];
default = "file";
};
files = mkOption {
type = types.submodule {
options = {
paths = mkOption {
type = types.listOf types.str;
default = [];
};
multiline = mkOption {
type = types.nullOr types.attrs;
default = null;
};
};
};
default = {};
};
journal = mkOption {
type = types.submodule {
options = {
path = mkOption {
type = types.str;
default = "/var/log/journal";
};
};
};
default = {};
};
labels = mkOption {
type = types.attrsOf types.str;
default = {};
};
pipelineStages = mkOption {
type = types.listOf types.attrs;
default = [];
};
enabled = mkOption {
type = types.bool;
default = true;
};
};
});
default = [];
};
defaultLabels = mkOption {
type = types.attrsOf types.str;
default = {
hostname = homelabCfg.hostname;
environment = homelabCfg.environment;
location = homelabCfg.location;
};
};
# Always exposed aggregated data
allSources = mkOption {
type = types.listOf types.attrs;
default = [];
readOnly = true;
};
global = mkOption {
type = types.attrs;
default = {};
readOnly = true;
};
};
config = mkIf cfg.enable {
# Local setup
services.promtail = mkIf cfg.promtail.enable {
enable = true;
configuration = {
server = {
http_listen_port = cfg.promtail.port;
grpc_listen_port = 0;
};
positions.filename = "/var/lib/promtail/positions.yaml";
clients = cfg.promtail.clients;
scrape_configs = map (source:
{
job_name = source.name;
static_configs = [
{
targets = ["localhost"];
labels =
cfg.defaultLabels
// source.labels
// (
if source.type == "file"
then {
__path__ = concatStringsSep "," source.files.paths;
}
else {}
);
}
];
# pipeline_stages = source.pipelineStages;
}
// (
if source.type == "journal"
then {
journal = {
path = source.journal.path;
labels = cfg.defaultLabels // source.labels;
};
}
else {}
))
localAggregation.allSources;
};
};
networking.firewall.allowedTCPPorts = optionals cfg.promtail.enable [cfg.promtail.port];
homelab.logging = {
allSources = localAggregation.allSources;
global =
if hasNodes
then globalAggregation
else {};
};
};
}

View file

@ -0,0 +1,222 @@
{
config,
lib,
nodes,
...
}:
with lib; let
cfg = config.homelab.monitoring;
homelabCfg = config.homelab;
hasNodes = length (attrNames nodes) > 0;
# Local aggregation from this instance
localAggregation = {
# Metrics from manually configured + automatic node exporter
allMetrics =
cfg.metrics
++ (optional cfg.nodeExporter.enable {
name = "node-exporter";
host = homelabCfg.hostname;
port = cfg.nodeExporter.port;
path = "/metrics";
jobName = "node";
scrapeInterval = "30s";
labels = {
instance = "${homelabCfg.hostname}.${homelabCfg.domain}";
environment = homelabCfg.environment;
location = homelabCfg.location;
};
});
allHealthChecks = cfg.healthChecks;
};
# Global aggregation from all nodes (when nodes available)
globalAggregation = let
baseAgg = import ../aggregators/base.nix {inherit lib;};
# Aggregate metrics from all nodes
metricsAgg = baseAgg.aggregateFromNodes {
inherit nodes;
attributePath = "homelab.monitoring.allMetrics";
enhancer = endpoint:
endpoint
// {
_fullAddress = "${endpoint.host}:${toString endpoint.port}";
_metricsUrl = "http://${endpoint.host}:${toString endpoint.port}${endpoint.path}";
};
};
# Aggregate health checks from all nodes
healthChecksAgg = baseAgg.aggregateFromNodes {
inherit nodes;
attributePath = "homelab.monitoring.allHealthChecks";
enhancer = check: let
actualHost = check.host;
portPart =
if check.port != null
then ":${toString check.port}"
else "";
url = "${check.protocol or "http"}://${actualHost}${portPart}${check.path}";
in
check
// {
_actualHost = actualHost;
_url = url;
};
};
in {
allMetrics = metricsAgg.all;
allHealthChecks = healthChecksAgg.all;
# Useful groupings for services
metricsByJobName = groupBy (m: m.jobName) metricsAgg.all;
healthChecksByGroup = groupBy (hc: hc.group or "default") healthChecksAgg.all;
summary = {
totalMetrics = length metricsAgg.all;
totalHealthChecks = length healthChecksAgg.all;
nodesCovered = unique (map (m: m._nodeName or m.host) metricsAgg.all);
};
};
in {
# Instance-level monitoring options
options.homelab.monitoring = {
enable = mkEnableOption "monitoring system";
# Node exporter (automatically enabled)
nodeExporter = {
enable = mkOption {
type = types.bool;
default = true;
};
port = mkOption {
type = types.port;
default = 9100;
};
};
# Manual metrics (in addition to service auto-registration)
metrics = mkOption {
type = types.listOf (types.submodule {
options = {
name = mkOption {type = types.str;};
host = mkOption {
type = types.str;
default = homelabCfg.hostname;
};
port = mkOption {type = types.port;};
path = mkOption {
type = types.str;
default = "/metrics";
};
jobName = mkOption {type = types.str;};
scrapeInterval = mkOption {
type = types.str;
default = "30s";
};
labels = mkOption {
type = types.attrsOf types.str;
default = {};
};
};
});
default = [];
};
# Manual health checks (in addition to service auto-registration)
healthChecks = mkOption {
type = types.listOf (types.submodule {
options = {
name = mkOption {type = types.str;};
host = mkOption {
type = types.str;
default = homelabCfg.hostname;
};
port = mkOption {
type = types.nullOr types.port;
default = null;
};
path = mkOption {
type = types.str;
default = "/";
};
protocol = mkOption {
type = types.enum ["http" "https" "tcp" "icmp"];
default = "http";
};
method = mkOption {
type = types.str;
default = "GET";
};
interval = mkOption {
type = types.str;
default = "30s";
};
timeout = mkOption {
type = types.str;
default = "10s";
};
conditions = mkOption {
type = types.listOf types.str;
default = ["[STATUS] == 200"];
};
group = mkOption {
type = types.str;
default = "manual";
};
labels = mkOption {
type = types.attrsOf types.str;
default = {};
};
enabled = mkOption {
type = types.bool;
default = true;
};
};
});
default = [];
};
# Read-only aggregated data (always exposed)
allMetrics = mkOption {
type = types.listOf types.attrs;
default = localAggregation.allMetrics;
readOnly = true;
};
allHealthChecks = mkOption {
type = types.listOf types.attrs;
default = localAggregation.allHealthChecks;
readOnly = true;
};
# Global aggregation (always available, empty if no nodes)
global = mkOption {
type = types.attrs;
default = globalAggregation;
readOnly = true;
};
};
# Configuration - always includes both local and global
config = mkIf cfg.enable {
# Basic instance setup
services.prometheus.exporters.node = mkIf cfg.nodeExporter.enable {
enable = true;
port = cfg.nodeExporter.port;
enabledCollectors = ["systemd" "textfile" "filesystem" "loadavg" "meminfo" "netdev" "stat"];
};
networking.firewall.allowedTCPPorts = optionals cfg.nodeExporter.enable [cfg.nodeExporter.port];
# homelab.monitoring = {
# allMetrics = localAggregation.allMetrics;
# allHealthChecks = localAggregation.allHealthChecks;
# global =
# if hasNodes
# then globalAggregation
# else {};
# };
};
}

View file

@ -0,0 +1,98 @@
{
config,
lib,
nodes,
...
}:
with lib; let
cfg = config.homelab.reverseProxy;
homelabCfg = config.homelab;
hasNodes = length (attrNames nodes) > 0;
# Local aggregation
localAggregation = {
allEntries = cfg.entries;
};
# Global aggregation
globalAggregation = let
baseAgg = import ../aggregators/base.nix {inherit lib;};
entriesAgg = baseAgg.aggregateFromNodes {
inherit nodes;
attributePath = "homelab.reverseProxy.allEntries";
enhancer = entry:
entry
// {
_upstream = "http://${entry.host}:${toString entry.port}${entry.path or ""}";
_fqdn = "${entry.subdomain}.${entry._nodeConfig.config.homelab.externalDomain or homelabCfg.externalDomain}";
_internal = "${entry.host}:${toString entry.port}";
};
};
in {
allEntries = entriesAgg.all;
entriesBySubdomain = groupBy (e: e.subdomain) entriesAgg.all;
entriesWithAuth = entriesAgg.filterBy (e: e.enableAuth or false);
entriesWithoutAuth = entriesAgg.filterBy (e: !(e.enableAuth or false));
summary = {
total = length entriesAgg.all;
byNode = entriesAgg.countBy (e: e._nodeName);
withAuth = length (entriesAgg.filterBy (e: e.enableAuth or false));
withoutAuth = length (entriesAgg.filterBy (e: !(e.enableAuth or false)));
};
};
in {
options.homelab.reverseProxy = {
enable = mkEnableOption "reverse proxy system";
entries = mkOption {
type = types.listOf (types.submodule {
options = {
subdomain = mkOption {type = types.str;};
host = mkOption {
type = types.str;
default = homelabCfg.hostname;
};
port = mkOption {type = types.port;};
path = mkOption {
type = types.str;
default = "/";
};
enableAuth = mkOption {
type = types.bool;
default = false;
};
enableSSL = mkOption {
type = types.bool;
default = true;
};
};
});
default = [];
};
# Always exposed aggregated data
allEntries = mkOption {
type = types.listOf types.attrs;
default = [];
readOnly = true;
};
global = mkOption {
type = types.attrs;
default = {};
readOnly = true;
};
};
config = mkIf cfg.enable {
# Always expose both local and global
homelab.reverseProxy = {
allEntries = localAggregation.allEntries;
global =
if hasNodes
then globalAggregation
else {};
};
};
}