nixos/modules: add nominatim module and test (#420050)

authored by Ivan Mincik and committed by GitHub 6d97fbb8 e21e8ef3

+523 -3
+1
nixos/modules/module-list.nix
··· 1413 ./services/search/hound.nix 1414 ./services/search/manticore.nix 1415 ./services/search/meilisearch.nix 1416 ./services/search/opensearch.nix 1417 ./services/search/qdrant.nix 1418 ./services/search/quickwit.nix
··· 1413 ./services/search/hound.nix 1414 ./services/search/manticore.nix 1415 ./services/search/meilisearch.nix 1416 + ./services/search/nominatim.nix 1417 ./services/search/opensearch.nix 1418 ./services/search/qdrant.nix 1419 ./services/search/quickwit.nix
+324
nixos/modules/services/search/nominatim.nix
···
··· 1 + { 2 + lib, 3 + config, 4 + pkgs, 5 + ... 6 + }: 7 + 8 + let 9 + cfg = config.services.nominatim; 10 + 11 + localDb = cfg.database.host == "localhost"; 12 + uiPackage = cfg.ui.package.override { customConfig = cfg.ui.config; }; 13 + in 14 + { 15 + options.services.nominatim = { 16 + enable = lib.mkOption { 17 + type = lib.types.bool; 18 + default = false; 19 + description = '' 20 + Whether to enable nominatim. 21 + 22 + Also enables nginx virtual host management. Further nginx configuration 23 + can be done by adapting `services.nginx.virtualHosts.<name>`. 24 + See [](#opt-services.nginx.virtualHosts). 25 + ''; 26 + }; 27 + 28 + package = lib.mkPackageOption pkgs.python3Packages "nominatim-api" { }; 29 + 30 + hostName = lib.mkOption { 31 + type = lib.types.str; 32 + description = "Hostname to use for the nginx vhost."; 33 + example = "nominatim.example.com"; 34 + }; 35 + 36 + settings = lib.mkOption { 37 + default = { }; 38 + type = lib.types.attrsOf lib.types.str; 39 + example = lib.literalExpression '' 40 + { 41 + NOMINATIM_REPLICATION_URL = "https://planet.openstreetmap.org/replication/minute"; 42 + NOMINATIM_REPLICATION_MAX_DIFF = "100"; 43 + } 44 + ''; 45 + description = '' 46 + Nominatim configuration settings. 47 + For the list of available configuration options see 48 + <https://nominatim.org/release-docs/latest/customize/Settings>. 49 + ''; 50 + }; 51 + 52 + ui = { 53 + package = lib.mkPackageOption pkgs "nominatim-ui" { }; 54 + 55 + config = lib.mkOption { 56 + type = lib.types.nullOr lib.types.str; 57 + default = null; 58 + description = '' 59 + Nominatim UI configuration placed to theme/config.theme.js file. 60 + 61 + For the list of available configuration options see 62 + <https://github.com/osm-search/nominatim-ui/blob/master/dist/config.defaults.js>. 63 + ''; 64 + example = '' 65 + Nominatim_Config.Page_Title='My Nominatim instance'; 66 + Nominatim_Config.Nominatim_API_Endpoint='https://localhost/'; 67 + ''; 68 + }; 69 + }; 70 + 71 + database = { 72 + host = lib.mkOption { 73 + type = lib.types.str; 74 + default = "localhost"; 75 + description = '' 76 + Host of the postgresql server. If not set to `localhost`, Nominatim 77 + database and postgresql superuser with appropriate permissions must 78 + exist on target host. 79 + ''; 80 + }; 81 + 82 + port = lib.mkOption { 83 + type = lib.types.port; 84 + default = 5432; 85 + description = "Port of the postgresql database."; 86 + }; 87 + 88 + dbname = lib.mkOption { 89 + type = lib.types.str; 90 + default = "nominatim"; 91 + description = "Name of the postgresql database."; 92 + }; 93 + 94 + superUser = lib.mkOption { 95 + type = lib.types.str; 96 + default = "nominatim"; 97 + description = '' 98 + Postgresql database superuser used to create Nominatim database and 99 + import data. If `database.host` is set to `localhost`, a unix user and 100 + group of the same name will be automatically created. 101 + ''; 102 + }; 103 + 104 + apiUser = lib.mkOption { 105 + type = lib.types.str; 106 + default = "nominatim-api"; 107 + description = '' 108 + Postgresql database user with read-only permissions used for Nominatim 109 + web API service. 110 + ''; 111 + }; 112 + 113 + passwordFile = lib.mkOption { 114 + type = lib.types.nullOr lib.types.path; 115 + default = null; 116 + description = '' 117 + Password file used for Nominatim database connection. 118 + Must be readable only for the Nominatim web API user. 119 + 120 + The file must be a valid `.pgpass` file as described in: 121 + <https://www.postgresql.org/docs/current/libpq-pgpass.html> 122 + 123 + In most cases, the following will be enough: 124 + ``` 125 + *:*:*:*:<password> 126 + ``` 127 + ''; 128 + }; 129 + 130 + extraConnectionParams = lib.mkOption { 131 + type = lib.types.nullOr lib.types.str; 132 + default = null; 133 + description = '' 134 + Extra Nominatim database connection parameters. 135 + 136 + Format: 137 + <param1>=<value1>;<param2>=<value2> 138 + 139 + See <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS>. 140 + ''; 141 + }; 142 + }; 143 + }; 144 + 145 + config = 146 + let 147 + nominatimSuperUserDsn = 148 + "pgsql:dbname=${cfg.database.dbname};" 149 + + "user=${cfg.database.superUser}" 150 + + lib.optionalString (cfg.database.extraConnectionParams != null) ( 151 + ";" + cfg.database.extraConnectionParams 152 + ); 153 + 154 + nominatimApiDsn = 155 + "pgsql:dbname=${cfg.database.dbname}" 156 + + lib.optionalString (!localDb) ( 157 + ";host=${cfg.database.host};" 158 + + "port=${toString cfg.database.port};" 159 + + "user=${cfg.database.apiUser}" 160 + ) 161 + + lib.optionalString (cfg.database.extraConnectionParams != null) ( 162 + ";" + cfg.database.extraConnectionParams 163 + ); 164 + in 165 + lib.mkIf cfg.enable { 166 + # CLI package 167 + environment.systemPackages = [ pkgs.nominatim ]; 168 + 169 + # Database 170 + users.users.${cfg.database.superUser} = lib.mkIf localDb { 171 + group = cfg.database.superUser; 172 + isSystemUser = true; 173 + createHome = false; 174 + }; 175 + users.groups.${cfg.database.superUser} = lib.mkIf localDb { }; 176 + 177 + services.postgresql = lib.mkIf localDb { 178 + enable = true; 179 + extensions = ps: with ps; [ postgis ]; 180 + ensureUsers = [ 181 + { 182 + name = cfg.database.superUser; 183 + ensureClauses.superuser = true; 184 + } 185 + { 186 + name = cfg.database.apiUser; 187 + } 188 + ]; 189 + }; 190 + 191 + # TODO: add nominatim-update service 192 + 193 + systemd.services.nominatim-init = lib.mkIf localDb { 194 + after = [ "postgresql-setup.service" ]; 195 + requires = [ "postgresql-setup.service" ]; 196 + wantedBy = [ "multi-user.target" ]; 197 + serviceConfig = { 198 + Type = "oneshot"; 199 + User = cfg.database.superUser; 200 + RemainAfterExit = true; 201 + PrivateTmp = true; 202 + }; 203 + script = '' 204 + sql="SELECT COUNT(*) FROM pg_database WHERE datname='${cfg.database.dbname}'" 205 + db_exists=$(${pkgs.postgresql}/bin/psql --dbname postgres -tAc "$sql") 206 + 207 + if [ "$db_exists" == "0" ]; then 208 + ${lib.getExe pkgs.nominatim} import --prepare-database 209 + else 210 + echo "Database ${cfg.database.dbname} already exists. Skipping ..." 211 + fi 212 + ''; 213 + path = [ 214 + pkgs.postgresql 215 + ]; 216 + environment = { 217 + NOMINATIM_DATABASE_DSN = nominatimSuperUserDsn; 218 + NOMINATIM_DATABASE_WEBUSER = cfg.database.apiUser; 219 + } // cfg.settings; 220 + }; 221 + 222 + # Web API service 223 + users.users.${cfg.database.apiUser} = { 224 + group = cfg.database.apiUser; 225 + isSystemUser = true; 226 + createHome = false; 227 + }; 228 + users.groups.${cfg.database.apiUser} = { }; 229 + 230 + systemd.services.nominatim = { 231 + after = [ "network.target" ] ++ lib.optionals localDb [ "nominatim-init.service" ]; 232 + requires = lib.optionals localDb [ "nominatim-init.service" ]; 233 + bindsTo = lib.optionals localDb [ "postgresql.service" ]; 234 + wantedBy = [ "multi-user.target" ]; 235 + wants = [ "network.target" ]; 236 + serviceConfig = { 237 + Type = "simple"; 238 + User = cfg.database.apiUser; 239 + ExecStart = '' 240 + ${pkgs.python3Packages.gunicorn}/bin/gunicorn \ 241 + --bind unix:/run/nominatim.sock \ 242 + --workers 4 \ 243 + --worker-class uvicorn.workers.UvicornWorker "nominatim_api.server.falcon.server:run_wsgi()" 244 + ''; 245 + Environment = lib.optional ( 246 + cfg.database.passwordFile != null 247 + ) "PGPASSFILE=${cfg.database.passwordFile}"; 248 + ExecReload = "${pkgs.procps}/bin/kill -s HUP $MAINPID"; 249 + KillMode = "mixed"; 250 + TimeoutStopSec = 5; 251 + }; 252 + environment = { 253 + PYTHONPATH = 254 + with pkgs.python3Packages; 255 + pkgs.python3Packages.makePythonPath [ 256 + cfg.package 257 + falcon 258 + uvicorn 259 + ]; 260 + NOMINATIM_DATABASE_DSN = nominatimApiDsn; 261 + NOMINATIM_DATABASE_WEBUSER = cfg.database.apiUser; 262 + } // cfg.settings; 263 + }; 264 + 265 + systemd.sockets.nominatim = { 266 + before = [ "nominatim.service" ]; 267 + wantedBy = [ "sockets.target" ]; 268 + socketConfig = { 269 + ListenStream = "/run/nominatim.sock"; 270 + SocketUser = cfg.database.apiUser; 271 + SocketGroup = config.services.nginx.group; 272 + }; 273 + }; 274 + 275 + services.nginx = { 276 + enable = true; 277 + appendHttpConfig = '' 278 + map $args $format { 279 + default default; 280 + ~(^|&)format=html(&|$) html; 281 + } 282 + 283 + map $uri/$format $forward_to_ui { 284 + default 0; # No forwarding by default. 285 + 286 + # Redirect to HTML UI if explicitly requested. 287 + ~/reverse.*/html 1; 288 + ~/search.*/html 1; 289 + ~/lookup.*/html 1; 290 + ~/details.*/html 1; 291 + } 292 + ''; 293 + upstreams.nominatim = { 294 + servers = { 295 + "unix:/run/nominatim.sock" = { }; 296 + }; 297 + }; 298 + virtualHosts = { 299 + ${cfg.hostName} = { 300 + forceSSL = lib.mkDefault true; 301 + enableACME = lib.mkDefault true; 302 + locations = { 303 + "= /" = { 304 + extraConfig = '' 305 + return 301 $scheme://$http_host/ui/search.html; 306 + ''; 307 + }; 308 + "/" = { 309 + proxyPass = "http://nominatim"; 310 + extraConfig = '' 311 + if ($forward_to_ui) { 312 + rewrite ^(/[^/.]*) /ui$1.html redirect; 313 + } 314 + ''; 315 + }; 316 + "/ui/" = { 317 + alias = "${uiPackage}/"; 318 + }; 319 + }; 320 + }; 321 + }; 322 + }; 323 + }; 324 + }
+1
nixos/tests/all-tests.nix
··· 1014 nixseparatedebuginfod = runTest ./nixseparatedebuginfod.nix; 1015 node-red = runTest ./node-red.nix; 1016 nomad = runTest ./nomad.nix; 1017 non-default-filesystems = handleTest ./non-default-filesystems.nix { }; 1018 non-switchable-system = runTest ./non-switchable-system.nix; 1019 noto-fonts = runTest ./noto-fonts.nix;
··· 1014 nixseparatedebuginfod = runTest ./nixseparatedebuginfod.nix; 1015 node-red = runTest ./node-red.nix; 1016 nomad = runTest ./nomad.nix; 1017 + nominatim = runTest ./nominatim.nix; 1018 non-default-filesystems = handleTest ./non-default-filesystems.nix { }; 1019 non-switchable-system = runTest ./non-switchable-system.nix; 1020 noto-fonts = runTest ./noto-fonts.nix;
+187
nixos/tests/nominatim.nix
···
··· 1 + { pkgs, lib, ... }: 2 + 3 + let 4 + # Andorra - the smallest dataset in Europe (3.1 MB) 5 + osmData = pkgs.fetchurl { 6 + url = "https://web.archive.org/web/20250430211212/https://download.geofabrik.de/europe/andorra-latest.osm.pbf"; 7 + hash = "sha256-Ey+ipTOFUm80rxBteirPW5N4KxmUsg/pCE58E/2rcyE="; 8 + }; 9 + in 10 + { 11 + name = "nominatim"; 12 + meta = { 13 + maintainers = with lib.teams; [ 14 + geospatial 15 + ngi 16 + ]; 17 + }; 18 + 19 + nodes = { 20 + # nominatim - self contained host 21 + nominatim = 22 + { config, pkgs, ... }: 23 + { 24 + # Nominatim 25 + services.nominatim = { 26 + enable = true; 27 + hostName = "nominatim"; 28 + settings = { 29 + NOMINATIM_IMPORT_STYLE = "admin"; 30 + }; 31 + ui = { 32 + config = '' 33 + Nominatim_Config.Page_Title='Test Nominatim instance'; 34 + Nominatim_Config.Nominatim_API_Endpoint='https://localhost/'; 35 + ''; 36 + }; 37 + }; 38 + 39 + # Disable SSL 40 + services.nginx.virtualHosts.nominatim = { 41 + forceSSL = false; 42 + enableACME = false; 43 + }; 44 + 45 + # Database 46 + services.postgresql = { 47 + enableTCPIP = true; 48 + authentication = lib.mkForce '' 49 + local all all trust 50 + host all all 0.0.0.0/0 md5 51 + host all all ::0/0 md5 52 + ''; 53 + }; 54 + systemd.services.postgresql-setup.postStart = '' 55 + psql --command "ALTER ROLE \"nominatim-api\" WITH PASSWORD 'password';" 56 + ''; 57 + networking.firewall.allowedTCPPorts = [ config.services.postgresql.settings.port ]; 58 + }; 59 + 60 + # api - web API only 61 + api = 62 + { config, pkgs, ... }: 63 + { 64 + # Database password 65 + system.activationScripts = { 66 + passwordFile.text = with config.services.nominatim.database; '' 67 + mkdir -p /run/secrets 68 + echo "${host}:${toString port}:${dbname}:${apiUser}:password" \ 69 + > /run/secrets/pgpass 70 + chown nominatim-api:nominatim-api /run/secrets/pgpass 71 + chmod 0600 /run/secrets/pgpass 72 + ''; 73 + }; 74 + 75 + # Nominatim 76 + services.nominatim = { 77 + enable = true; 78 + hostName = "nominatim"; 79 + settings = { 80 + NOMINATIM_LOG_DB = "yes"; 81 + }; 82 + database = { 83 + host = "nominatim"; 84 + passwordFile = "/run/secrets/pgpass"; 85 + extraConnectionParams = "application_name=nominatim;connect_timeout=2"; 86 + }; 87 + }; 88 + 89 + # Disable SSL 90 + services.nginx.virtualHosts.nominatim = { 91 + forceSSL = false; 92 + enableACME = false; 93 + }; 94 + }; 95 + }; 96 + 97 + testScript = '' 98 + # Test nominatim host 99 + nominatim.start() 100 + nominatim.wait_for_unit("nominatim.service") 101 + 102 + # Import OSM data 103 + nominatim.succeed(""" 104 + cd /tmp 105 + sudo -u nominatim \ 106 + NOMINATIM_DATABASE_WEBUSER=nominatim-api \ 107 + NOMINATIM_IMPORT_STYLE=admin \ 108 + nominatim import --continue import-from-file --osm-file ${osmData} 109 + """) 110 + nominatim.succeed("systemctl restart nominatim.service") 111 + 112 + # Test CLI 113 + nominatim.succeed("sudo -u nominatim-api nominatim search --query Andorra") 114 + 115 + # Test web API 116 + nominatim.succeed("curl 'http://localhost/status' | grep OK") 117 + 118 + nominatim.succeed(""" 119 + curl "http://localhost/search?q=Andorra&format=geojson" | grep "Andorra" 120 + curl "http://localhost/reverse?lat=42.5407167&lon=1.5732033&format=geojson" 121 + """) 122 + 123 + # Test UI 124 + nominatim.succeed(""" 125 + curl "http://localhost/ui/search.html" \ 126 + | grep "<title>Nominatim Demo</title>" 127 + """) 128 + 129 + 130 + # Test api host 131 + api.start() 132 + api.wait_for_unit("nominatim.service") 133 + 134 + # Test web API 135 + api.succeed(""" 136 + curl "http://localhost/search?q=Andorra&format=geojson" | grep "Andorra" 137 + curl "http://localhost/reverse?lat=42.5407167&lon=1.5732033&format=geojson" 138 + """) 139 + 140 + 141 + # Test format rewrites 142 + # Redirect / to search 143 + nominatim.succeed(""" 144 + curl --verbose "http://localhost" 2>&1 \ 145 + | grep "Location: http://localhost/ui/search.html" 146 + """) 147 + 148 + # Return text by default 149 + nominatim.succeed(""" 150 + curl --verbose "http://localhost/status" 2>&1 \ 151 + | grep "Content-Type: text/plain" 152 + """) 153 + 154 + # Return JSON by default 155 + nominatim.succeed(""" 156 + curl --verbose "http://localhost/search?q=Andorra" 2>&1 \ 157 + | grep "Content-Type: application/json" 158 + """) 159 + 160 + # Return XML by default 161 + nominatim.succeed(""" 162 + curl --verbose "http://localhost/lookup" 2>&1 \ 163 + | grep "Content-Type: text/xml" 164 + 165 + curl --verbose "http://localhost/reverse?lat=0&lon=0" 2>&1 \ 166 + | grep "Content-Type: text/xml" 167 + """) 168 + 169 + # Redirect explicitly requested HTML format 170 + nominatim.succeed(""" 171 + curl --verbose "http://localhost/search?format=html" 2>&1 \ 172 + | grep "Location: http://localhost/ui/search.html" 173 + 174 + curl --verbose "http://localhost/reverse?format=html" 2>&1 \ 175 + | grep "Location: http://localhost/ui/reverse.html" 176 + """) 177 + 178 + # Return explicitly requested JSON format 179 + nominatim.succeed(""" 180 + curl --verbose "http://localhost/search?format=json" 2>&1 \ 181 + | grep "Content-Type: application/json" 182 + 183 + curl --verbose "http://localhost/reverse?format=json" 2>&1 \ 184 + | grep "Content-Type: application/json" 185 + """) 186 + ''; 187 + }
+6 -1
pkgs/by-name/no/nominatim-ui/package.nix
··· 3 stdenv, 4 fetchFromGitHub, 5 fetchYarnDeps, 6 writableTmpDirAsHomeHook, 7 writeText, 8 ··· 10 nodejs, 11 yarn, 12 13 - # Custom application configuration placed to theme/config.theme.js file 14 # For the list of available configuration options see 15 # https://github.com/osm-search/nominatim-ui/blob/master/dist/config.defaults.js 16 customConfig ? null, ··· 82 83 runHook postInstall 84 ''; 85 86 meta = { 87 description = "Debugging user interface for Nominatim geocoder";
··· 3 stdenv, 4 fetchFromGitHub, 5 fetchYarnDeps, 6 + nixosTests, 7 writableTmpDirAsHomeHook, 8 writeText, 9 ··· 11 nodejs, 12 yarn, 13 14 + # Custom application configuration placed to theme/config.theme.js file. 15 # For the list of available configuration options see 16 # https://github.com/osm-search/nominatim-ui/blob/master/dist/config.defaults.js 17 customConfig ? null, ··· 83 84 runHook postInstall 85 ''; 86 + 87 + passthru.tests = { 88 + inherit (nixosTests) nominatim; 89 + }; 90 91 meta = { 92 description = "Debugging user interface for Nominatim geocoder";
+4 -2
pkgs/by-name/no/nominatim/package.nix
··· 7 python3Packages, 8 9 nominatim, # required for testVersion 10 testers, 11 }: 12 ··· 64 65 pythonImportsCheck = [ "nominatim_db" ]; 66 67 - passthru = { 68 - tests.version = testers.testVersion { package = nominatim; }; 69 }; 70 71 meta = {
··· 7 python3Packages, 8 9 nominatim, # required for testVersion 10 + nixosTests, 11 testers, 12 }: 13 ··· 65 66 pythonImportsCheck = [ "nominatim_db" ]; 67 68 + passthru.tests = { 69 + version = testers.testVersion { package = nominatim; }; 70 + inherit (nixosTests) nominatim; 71 }; 72 73 meta = {