Files
archived-teslamate/nix/module.nix
JakobLichterfeld 6c19792d07 feat(nix): add idiomatic maintenance scripts (#4849)
* feat(nix): add idiomatic maintenance scripts

* docs: update changelog

* docs: update changelog

* fix(nix): pass required arguments to maintenance.nix (getExe, teslamate)

* fix(nix): escape ${1} in maintenance shell scripts to prevent evaluation errors

* fix(nix): make RELEASE_COOKIE available to maintenance scripts by exporting it after sourcing

* docs: add reference to idiomatic nix backup and restore scripts

* docs: add reference to idiomatic nix maintenance scripts
2025-08-06 14:37:55 +02:00

368 lines
11 KiB
Nix

{ self }:
{ config
, lib
, pkgs
, ...
}:
let
teslamate = self.packages.${pkgs.system}.default;
cfg = config.services.teslamate;
inherit (lib)
mkPackageOption
mkEnableOption
mkOption
types
mkIf
mkMerge
getExe
literalExpression
;
in
{
options.services.teslamate = {
enable = mkEnableOption "Teslamate";
secretsFile = mkOption {
type = types.str;
example = "/run/secrets/teslamate.env";
description = lib.mdDoc ''
Path to an env file containing the secrets used by TeslaMate.
Must contain at least:
- `ENCRYPTION_KEY` - encryption key used to encrypt database
- `DATABASE_PASS` - password used to authenticate to database
- `RELEASE_COOKIE` - unique value used by elixir for clustering
'';
};
autoStart = mkOption {
type = types.bool;
default = true;
description = "Whether to start teslamate on boot.";
};
listenAddress = mkOption {
type = with types; nullOr str;
default = null;
example = "127.0.0.1";
description = "IP address where the web interface is exposed or `null` for all addresses";
};
port = mkOption {
type = types.port;
default = 4000;
description = "Port the TeslaMate service will listen on";
};
virtualHost = mkOption {
type = types.str;
default = if config.networking.domain == null then "localhost" else config.networking.fqdn;
defaultText = literalExpression ''
if config.networking.domain == null then "localhost" else config.networking.fqdn
'';
description = "Host part used for generating URLs throughout the app. Will be combined with urlPath";
};
urlPath = mkOption {
type = types.str;
default = "/";
description = "Path prefix used for generating URLs throughout the app. Will be combined with virtualHost";
};
postgres = {
enable_server = mkOption {
type = types.bool;
default = false;
description = lib.mdDoc ''
Whether to create a postgres server with the recommended configuration.
Other settings will still be used even if `enable` is false to configure
database connection.
'';
};
package = mkPackageOption pkgs "postgresql_17" {
extraDescription = ''
The postgresql package to use.
'';
};
user = mkOption {
type = types.str;
default = "teslamate";
description = "PostgresQL database user";
};
database = mkOption {
type = types.str;
default = "teslamate";
description = "PostgresQL database to connect to";
};
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Hostname of the database server";
};
port = mkOption {
type = types.port;
default = 5432;
description = "Postgresql database port. Must be correct even if `services.teslamate.postgres.enable` is false";
};
};
grafana = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to create and provision grafana with the TeslaMate dashboards";
};
listenAddress = mkOption {
type = types.str;
default = "0.0.0.0";
description = "IP address for grafana to listen to.";
};
port = mkOption {
type = types.port;
default = 3000;
description = "Port for grafana web service";
};
urlPath = mkOption {
type = types.str;
default = "/";
description = "Path that grafana is mounted on. Useful if using a reverse proxy to vend teslamate and grafana on the same port";
};
setDefaultDashboard = mkOption {
type = types.bool;
default = true;
description = "Whether to set the TeslaMate home dashboard as the default dashboard in Grafana";
};
};
mqtt = {
enable = mkEnableOption "TeslaMate MQTT integration";
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = "MQTT host";
};
port = mkOption {
type = with types; nullOr port;
default = null;
example = 1883;
description = "MQTT port.";
};
};
};
config = mkIf cfg.enable (mkMerge [
{
users.users.teslamate = {
isSystemUser = true;
group = "teslamate";
home = "/var/lib/teslamate";
createHome = true;
};
users.groups.teslamate = { };
systemd.services.teslamate = {
description = "TeslaMate";
after = [
"network.target"
"postgresql.service"
"mosquitto.service"
];
wantedBy = mkIf cfg.autoStart [ "multi-user.target" ];
serviceConfig = {
User = "teslamate";
Restart = "on-failure";
RestartSec = 5;
WorkingDirectory = "/var/lib/teslamate";
ExecStartPre = ''${getExe teslamate} eval "TeslaMate.Release.migrate"'';
ExecStart = "${getExe teslamate} start";
ExecStop = "${getExe teslamate} stop";
EnvironmentFile = cfg.secretsFile;
};
environment = mkMerge [
{
PORT = toString cfg.port;
DATABASE_USER = cfg.postgres.user;
DATABASE_NAME = cfg.postgres.database;
DATABASE_HOST = cfg.postgres.host;
DATABASE_PORT = toString cfg.postgres.port;
VIRTUAL_HOST = cfg.virtualHost;
URL_PATH = cfg.urlPath;
HTTP_BINDING_ADDRESS = mkIf (cfg.listenAddress != null) cfg.listenAddress;
DISABLE_MQTT = mkIf (!cfg.mqtt.enable) "true";
}
(mkIf cfg.mqtt.enable {
MQTT_HOST = cfg.mqtt.host;
MQTT_PORT = mkIf (cfg.mqtt.port != null) (toString cfg.mqtt.port);
})
];
};
# idiomatic backup and restore and maintenance scripts
environment.systemPackages = with pkgs; [
(callPackage ./backup_and_restore.nix {
databaseUser = cfg.postgres.user;
databaseName = cfg.postgres.database;
})
(callPackage ./maintenance.nix {
databaseUser = cfg.postgres.user;
databaseName = cfg.postgres.database;
environmentFilePath = cfg.secretsFile;
getExe = getExe;
teslamate = teslamate;
})
];
}
(mkIf cfg.postgres.enable_server {
services.postgresql = {
enable = true;
inherit (cfg.postgres) package;
settings = {
inherit (cfg.postgres) port;
};
initialScript = pkgs.writeText "teslamate-psql-init" ''
\set password `echo $DATABASE_PASS`
CREATE DATABASE ${cfg.postgres.database};
CREATE USER ${cfg.postgres.user} with encrypted password :'password';
GRANT ALL PRIVILEGES ON DATABASE ${cfg.postgres.database} TO ${cfg.postgres.user};
ALTER USER ${cfg.postgres.user} WITH SUPERUSER;
'';
};
# Include secrets in postgres as well
systemd.services.postgresql = {
serviceConfig = {
EnvironmentFile = cfg.secretsFile;
};
};
})
(mkIf cfg.grafana.enable {
services.grafana = {
enable = true;
settings = {
server = {
domain = cfg.virtualHost;
http_port = cfg.grafana.port;
http_addr = cfg.grafana.listenAddress;
root_url = "http://%(domain)s${cfg.grafana.urlPath}";
serve_from_sub_path = cfg.grafana.urlPath != "/";
};
security = {
allow_embedding = true;
disable_gravatar = true;
};
users = {
allow_sign_up = false;
default_language = "detect";
};
"auth.anonymous".enabled = false;
"auth.basic".enabled = false;
analytics.reporting_enabled = false;
dashboards.default_home_dashboard_path = mkIf cfg.grafana.setDefaultDashboard "${pkgs.lib.sources.sourceFilesBySuffices ../grafana/dashboards/internal [".json"]}/home.json";
date_formats.use_browser_locale = true;
plugins.preinstall_disabled = true;
unified_alerting.enabled = false;
};
provision = {
enable = true;
datasources.settings.datasources = [
# extracted from ../grafana/datasource.yml
{
name = "TeslaMate";
type = "postgres";
url = "http://${cfg.postgres.host}:${toString cfg.postgres.port}";
user = cfg.postgres.user;
access = "proxy";
basicAuth = false;
withCredentials = false;
isDefault = true;
secureJsonData.password = "\${DATABASE_PASS}";
jsonData = {
postgresVersion = 1500;
sslmode = "disable";
database = cfg.postgres.database;
};
version = 1;
editable = true;
}
];
# Need to duplicate dashboards.yml since it contains absolute paths
# which are incompatible with NixOS
dashboards.settings = {
apiVersion = 1;
providers = [
{
name = "teslamate";
orgId = 1;
folder = "TeslaMate";
folderUid = "Nr4ofiDZk";
type = "file";
disableDeletion = false;
allowUiUpdates = true;
updateIntervalSeconds = 86400;
options.path = lib.sources.sourceByRegex
../grafana/dashboards
[ "^[^\/]*\.json$" ];
}
{
name = "teslamate_internal";
orgId = 1;
folder = "Internal";
folderUid = "Nr5ofiDZk";
type = "file";
disableDeletion = false;
allowUiUpdates = true;
updateIntervalSeconds = 86400;
options.path = lib.sources.sourceFilesBySuffices
../grafana/dashboards/internal
[ ".json" ];
}
{
name = "teslamate_reports";
orgId = 1;
folder = "Reports";
folderUid = "Nr6ofiDZk";
type = "file";
disableDeletion = false;
allowUiUpdates = true;
updateIntervalSeconds = 86400;
options.path = lib.sources.sourceFilesBySuffices
../grafana/dashboards/reports
[ ".json" ];
}
];
};
};
};
systemd.services.grafana = {
serviceConfig.EnvironmentFile = cfg.secretsFile;
environment = {
DATABASE_USER = cfg.postgres.user;
DATABASE_NAME = cfg.postgres.database;
DATABASE_HOST = cfg.postgres.host;
DATABASE_PORT = toString cfg.postgres.port;
DATABASE_SSL_MODE = "disable";
};
};
})
]);
}