diff --git a/.kitchen.yml b/.kitchen.yml
index db9664940..a6f471386 100644
--- a/.kitchen.yml
+++ b/.kitchen.yml
@@ -406,6 +406,9 @@ suites:
- name: trac
run_list:
- recipe[trac::default]
+ - name: vectortile
+ run_list:
+ - recipe[vectortile::default]
- name: web-cgimap
run_list:
- recipe[web::cgimap]
diff --git a/cookbooks/postgresql/libraries/postgresql.rb b/cookbooks/postgresql/libraries/postgresql.rb
index 1a39da25a..dacf885db 100644
--- a/cookbooks/postgresql/libraries/postgresql.rb
+++ b/cookbooks/postgresql/libraries/postgresql.rb
@@ -4,6 +4,10 @@ module OpenStreetMap
class PostgreSQL
include Chef::Mixin::ShellOut
+ SCHEMA_PRIVILEGES = [
+ :create, :usage
+ ].freeze
+
TABLE_PRIVILEGES = [
:select, :insert, :update, :delete, :truncate, :references, :trigger
].freeze
@@ -115,6 +119,18 @@ def tablespaces
end
end
+ def schemas(database)
+ @schemas ||= {}
+ @schemas[database] ||= query("SELECT n.nspname, pg_catalog.pg_get_userbyid(n.nspowner) AS usename, n.nspacl FROM pg_namespace AS n WHERE n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'", :database => database).each_with_object({}) do |schema, schemas|
+ name = "#{schema[:nspname]}"
+
+ schemas[name] = {
+ :owner => schema[:usename],
+ :permissions => parse_acl(schema[:nspacl] || "{}")
+ }
+ end
+ end
+
def tables(database)
@tables ||= {}
@tables[database] ||= query("SELECT n.nspname, c.relname, u.usename, c.relacl FROM pg_class AS c INNER JOIN pg_user AS u ON c.relowner = u.usesysid INNER JOIN pg_namespace AS n ON c.relnamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND c.relkind = 'r'", :database => database).each_with_object({}) do |table, tables|
diff --git a/cookbooks/postgresql/resources/schema.rb b/cookbooks/postgresql/resources/schema.rb
new file mode 100644
index 000000000..a7bf0ebdb
--- /dev/null
+++ b/cookbooks/postgresql/resources/schema.rb
@@ -0,0 +1,114 @@
+#
+# Cookbook:: postgresql
+# Resource:: postgresql_schema
+#
+# Copyright:: 2024, OpenStreetMap Foundation
+#
+# Licensed 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
+#
+# https://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.
+#
+
+unified_mode true
+
+default_action :create
+
+property :schema, :kind_of => String, :name_property => true
+property :cluster, :kind_of => String, :required => true
+property :database, :kind_of => String, :required => true
+property :owner, :kind_of => String, :required => [:create]
+property :permissions, :kind_of => Hash, :default => {}
+
+action :create do
+ if schemas.include?(qualified_name)
+ # Handle the case of an existing schema
+ if new_resource.owner != schemas[qualified_name][:owner]
+ converge_by("set owner for #{new_resource} to #{new_resource.owner}") do
+ Chef::Log.info("Setting owner for #{new_resource} to #{new_resource.owner}")
+ cluster.execute(:command => "ALTER SCHEMA #{qualified_name} OWNER TO \"#{new_resource.owner}\"", :database => new_resource.database)
+ end
+ end
+
+ schemas[qualified_name][:permissions].each_key do |user|
+ next if new_resource.permissions[user]
+ # Remove permissions from users who no longer have them
+ converge_by("revoke all for #{user} on #{new_resource}") do
+ Chef::Log.info("Revoking all for #{user} on #{new_resource}")
+ cluster.execute(:command => "REVOKE ALL ON SCHEMA #{qualified_name} FROM \"#{user}\"", :database => new_resource.database)
+ end
+ end
+ new_resource.permissions.each do |user, new_privileges|
+ current_privileges = schemas[qualified_name][:permissions][user] || {}
+ new_privileges = Array(new_privileges)
+
+ if new_privileges.include?(:all)
+ new_privileges |= OpenStreetMap::PostgreSQL::SCHEMA_PRIVILEGES
+ end
+
+ OpenStreetMap::PostgreSQL::SCHEMA_PRIVILEGES.each do |privilege|
+ if new_privileges.include?(privilege)
+ unless current_privileges.include?(privilege)
+ converge_by("grant #{privilege} for #{user} on #{new_resource}") do
+ Chef::Log.info("Granting #{privilege} for #{user} on #{new_resource}")
+ cluster.execute(:command => "GRANT #{privilege.to_s.upcase} ON SCHEMA #{qualified_name} TO \"#{user}\"", :database => new_resource.database)
+ end
+ end
+ elsif current_privileges.include?(privilege)
+ converge_by("revoke #{privilege} for #{user} on #{new_resource}") do
+ Chef::Log.info("Revoking #{privilege} for #{user} on #{new_resource}")
+ cluster.execute(:command => "REVOKE #{privilege.to_s.upcase} ON SCHEMA #{qualified_name} FROM \"#{user}\"", :database => new_resource.database)
+ end
+ end
+ end
+ end
+ else
+ converge_by "create schema #{new_resource.schema}" do
+ cluster.execute(:command => "CREATE SCHEMA #{new_resource.schema} AUTHORIZATION #{new_resource.owner}", :database => new_resource.database)
+ # Because the schema is new, we don't have to worry about revoking or checking current permissions
+ new_resource.permissions.each do |user, new_privileges|
+ new_privileges = Array(new_privileges)
+ if new_privileges.include?(:all)
+ new_privileges |= OpenStreetMap::PostgreSQL::SCHEMA_PRIVILEGES
+ end
+ OpenStreetMap::PostgreSQL::SCHEMA_PRIVILEGES.each do |privilege|
+ next unless new_privileges.include?(privilege)
+ converge_by("grant #{privilege} for #{user} on #{new_resource}") do
+ Chef::Log.info("Granting #{privilege} for #{user} on #{new_resource}")
+ cluster.execute(:command => "GRANT #{privilege.to_s.upcase} ON SCHEMA #{qualified_name} TO \"#{user}\"", :database => new_resource.database)
+ end
+ end
+ end
+ end
+ end
+end
+
+action :drop do
+ if schemas.include?(qualified_name)
+ converge_by("drop #{new_resource}") do
+ Chef::Log.info("Dropping #{new_resource}")
+ cluster.execute(:command => "DROP SCHEMA #{qualified_name}", :database => new_resource.database)
+ end
+ end
+end
+
+action_class do
+ def cluster
+ @cluster ||= OpenStreetMap::PostgreSQL.new(new_resource.cluster)
+ end
+
+ def schemas
+ @schemas ||= cluster.schemas(new_resource.database)
+ end
+
+ def qualified_name
+ "#{new_resource.name}"
+ end
+end
diff --git a/cookbooks/vectortile/README.md b/cookbooks/vectortile/README.md
new file mode 100644
index 000000000..4e89e0ab6
--- /dev/null
+++ b/cookbooks/vectortile/README.md
@@ -0,0 +1,9 @@
+# vectortile cookbook
+
+This cookbook installs and configures the tilekiln based tileservers
+
+## Accounts
+The following accounts are used
+- `www-data` for nginx serving static files and proxying
+- `tilekiln` for the process serving tiles
+- `update` for the process running osm2pgsql and tilekiln on updates
diff --git a/cookbooks/vectortile/attributes/default.rb b/cookbooks/vectortile/attributes/default.rb
new file mode 100644
index 000000000..343c955ee
--- /dev/null
+++ b/cookbooks/vectortile/attributes/default.rb
@@ -0,0 +1,9 @@
+default[:vectortile][:database][:cluster] = "16/main"
+default[:vectortile][:database][:postgis] = "3"
+default[:vectortile][:serve][:threads] = node.cpu_cores
+
+default[:postgresql][:versions] |= [node[:vectortile][:database][:cluster].split("/").first]
+default[:postgresql][:monitor_database] = "tiles"
+
+default[:accounts][:users][:tileupdate][:status] = :role
+default[:accounts][:users][:tilekiln][:status] = :role
diff --git a/cookbooks/vectortile/files/default/html/clientaccesspolicy.xml b/cookbooks/vectortile/files/default/html/clientaccesspolicy.xml
new file mode 100644
index 000000000..d60ec1d3f
--- /dev/null
+++ b/cookbooks/vectortile/files/default/html/clientaccesspolicy.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cookbooks/vectortile/files/default/html/crossdomain.xml b/cookbooks/vectortile/files/default/html/crossdomain.xml
new file mode 100644
index 000000000..35fc43447
--- /dev/null
+++ b/cookbooks/vectortile/files/default/html/crossdomain.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/cookbooks/vectortile/files/default/html/favicon.ico b/cookbooks/vectortile/files/default/html/favicon.ico
new file mode 100644
index 000000000..975e1cb0d
Binary files /dev/null and b/cookbooks/vectortile/files/default/html/favicon.ico differ
diff --git a/cookbooks/vectortile/files/default/html/robots.txt b/cookbooks/vectortile/files/default/html/robots.txt
new file mode 100644
index 000000000..bfb50b0ae
--- /dev/null
+++ b/cookbooks/vectortile/files/default/html/robots.txt
@@ -0,0 +1,13 @@
+User-agent: *
+Disallow: /12/
+Disallow: /13/
+Disallow: /14/
+Disallow: /15/
+Disallow: /16/
+Disallow: /17/
+Disallow: /18/
+Disallow: /19/
+Disallow: /20/
+Disallow: /21/
+Disallow: /22/
+Disallow: /23/
diff --git a/cookbooks/vectortile/metadata.rb b/cookbooks/vectortile/metadata.rb
new file mode 100644
index 000000000..d3b44e37e
--- /dev/null
+++ b/cookbooks/vectortile/metadata.rb
@@ -0,0 +1,16 @@
+name "vectortile"
+maintainer "OpenStreetMap Administrators"
+maintainer_email "admins@openstreetmap.org"
+license "Apache-2.0"
+description "Installs and configures vector tile servers"
+
+version "1.0.0"
+supports "ubuntu"
+depends "accounts"
+depends "git"
+depends "nginx"
+depends "postgresql"
+depends "prometheus"
+depends "python"
+depends "systemd"
+depends "tools"
diff --git a/cookbooks/vectortile/recipes/default.rb b/cookbooks/vectortile/recipes/default.rb
new file mode 100644
index 000000000..a1e11a26b
--- /dev/null
+++ b/cookbooks/vectortile/recipes/default.rb
@@ -0,0 +1,211 @@
+#
+# Cookbook:: vectortile
+# Recipe:: default
+#
+# Copyright:: 2024, OpenStreetMap Foundation
+#
+# Licensed 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
+#
+# https://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.
+#
+
+include_recipe "accounts"
+include_recipe "git"
+include_recipe "nginx"
+include_recipe "postgresql"
+include_recipe "prometheus"
+include_recipe "python"
+include_recipe "tools"
+
+directory "/srv/vector.openstreetmap.org" do
+ user "tileupdate"
+ group "tileupdate"
+ mode "755"
+end
+
+nginx_site "default" do
+ action [:delete]
+end
+
+nginx_site "vector.openstreetmap.org" do
+ template "nginx.erb"
+end
+
+ssl_certificate node[:fqdn] do
+ domains [node[:fqdn], "vector.openstreetmap.org"]
+ notifies :reload, "service[nginx]"
+end
+
+remote_directory "/srv/vector.openstreetmap.org/html" do
+ source "html"
+ owner "www-data"
+ group "www-data"
+ mode "755"
+ files_owner "www-data"
+ files_group "www-data"
+ files_mode "644"
+end
+
+template "/srv/vector.openstreetmap.org/html/index.html" do
+ source "index.html.erb"
+ owner "www-data"
+ group "www-data"
+ mode "644"
+end
+
+postgresql_version = node[:vectortile][:database][:cluster].split("/").first
+postgis_version = node[:vectortile][:database][:postgis]
+
+package "postgresql-#{postgresql_version}-postgis-#{postgis_version}"
+
+postgresql_user "tomh" do
+ cluster node[:vectortile][:database][:cluster]
+ superuser true
+end
+
+postgresql_user "pnorman" do
+ cluster node[:vectortile][:database][:cluster]
+ superuser true
+end
+
+postgresql_user "tilekiln" do
+ cluster node[:vectortile][:database][:cluster]
+end
+
+postgresql_user "tileupdate" do
+ cluster node[:vectortile][:database][:cluster]
+end
+
+postgresql_database "tiles" do
+ cluster node[:vectortile][:database][:cluster]
+ owner "tileupdate"
+end
+
+postgresql_schema "tilekiln" do
+ cluster node[:vectortile][:database][:cluster]
+ database "tiles"
+ owner "tileupdate"
+ permissions "tileupdate" => :all, "tilekiln" => :usage
+end
+
+postgresql_database "spirit" do
+ cluster node[:vectortile][:database][:cluster]
+ owner "tileupdate"
+end
+
+postgresql_extension "postgis" do
+ cluster node[:vectortile][:database][:cluster]
+ database "spirit"
+end
+
+# Get a recent osm2pgsql version with backports.
+apt_preference "osm2pgsql" do
+ pin "release o=Debian Backports"
+ pin_priority "600"
+end
+
+apt_package "osm2pgsql"
+
+style_directory = "/srv/vector.openstreetmap.org/spirit"
+git style_directory do
+ repository "https://github.com/pnorman/spirit.git"
+ # Check out head for now
+ # revision "61db723"
+ user "tileupdate"
+ group "tileupdate"
+end
+
+themepark_directory = "/srv/vector.openstreetmap.org/osm2pgsql-themepark"
+git themepark_directory do
+ repository "https://github.com/osm2pgsql-dev/osm2pgsql-themepark.git"
+ user "tileupdate"
+ group "tileupdate"
+end
+
+tilekiln_directory = "/opt/tilekiln"
+
+python_virtualenv tilekiln_directory do
+ interpreter "/usr/bin/python3"
+end
+
+python_package "tilekiln" do
+ python_virtualenv tilekiln_directory
+ python_version "3"
+ version "0.5.1"
+end
+
+template "/srv/vector.openstreetmap.org/html/index.html" do
+ source "index.html.erb"
+ owner "www-data"
+ group "www-data"
+ mode "644"
+end
+
+directory "/srv/vector.openstreetmap.org/data" do
+ user "tileupdate"
+ group "tileupdate"
+ mode "755"
+end
+
+template "/usr/local/bin/import-planet" do
+ source "import-planet.erb"
+ owner "root"
+ group "root"
+ mode "755"
+end
+
+template "/usr/local/bin/tilekiln-storage-init" do
+ source "tilekiln-storage-init.erb"
+ owner "root"
+ group "root"
+ mode "755"
+ variables :tilekiln_bin => "#{tilekiln_directory}/bin/tilekiln", :storage_database => "tiles", :config_path => "#{style_directory}/spirit.yaml"
+end
+
+directory "/srv/vector.openstreetmap.org/complete" do
+ user "root"
+ group "root"
+ mode "755"
+end
+
+execute "tilekiln-storage-init" do
+ command "/usr/local/bin/tilekiln-storage-init"
+ user "tileupdate"
+ not_if { ::File.exist?("/srv/vector.openstreetmap.org/complete/tilekiln-storage-init") }
+ notifies :create, "file[/srv/vector.openstreetmap.org/complete/tilekiln-storage-init]", :immediately
+end
+
+file "/srv/vector.openstreetmap.org/complete/tilekiln-storage-init" do
+ action :nothing
+ content "lockfile for tilekiln-storage-init"
+end
+
+postgresql_table "metadata" do
+ cluster node[:vectortile][:database][:cluster]
+ database "tiles"
+ schema "tilekiln"
+ owner "tileupdate"
+ permissions "tileupdate" => :all, "tilekiln" => :select
+end
+
+systemd_service "tilekiln" do
+ description "Tilekiln vector tile server"
+ user "tilekiln"
+ after "postgresql.service"
+ wants "postgresql.service"
+
+ exec_start "#{tilekiln_directory}/bin/tilekiln serve static --storage-dbname tiles --num-threads #{node[:vectortile][:serve][:threads]}"
+end
+
+service "tilekiln" do
+ action [:enable, :start]
+ supports :restart => true
+end
diff --git a/cookbooks/vectortile/templates/default/import-planet.erb b/cookbooks/vectortile/templates/default/import-planet.erb
new file mode 100644
index 000000000..3c36e092b
--- /dev/null
+++ b/cookbooks/vectortile/templates/default/import-planet.erb
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+# Usage
+# sudo -u tileupdate import osmfile.osm.pbf
+# sudo -u tileupdate import --cache 50000 osmfile.osm.pbf
+# This script sets the appropriate Lua and style paths for the osm2pgsql import
+
+set -e
+
+export LUA_PATH='/srv/vector.openstreetmap.org/osm2pgsql-themepark/lua/?.lua;/srv/vector.openstreetmap.org/spirit/?.lua;;'
+
+# Import the osm2pgsql file specified as an argument, using the locations for spirit
+osm2pgsql --output flex --style '/srv/vector.openstreetmap.org/spirit/spirit.lua' --slim \
+--flat-nodes '/srv/vector.openstreetmap.org/data/nodes.bin' -d spirit $@
+
+# Set up replication. This doesn't specify the replication server, so it will use planet.osm.org on extracts
+osm2pgsql-replication init -d spirit
diff --git a/cookbooks/vectortile/templates/default/index.html.erb b/cookbooks/vectortile/templates/default/index.html.erb
new file mode 100644
index 000000000..feb7f36fa
--- /dev/null
+++ b/cookbooks/vectortile/templates/default/index.html.erb
@@ -0,0 +1,19 @@
+
+
+
+vector.openstreetmap.org
+
+
+
+
+
+
+You've reached the OpenStreetMap.org vector tile server (<%= node['fqdn'] %>)
+
+- If you are a user...
+- You probably want OpenStreetMap itself.
+- If you are a developer...
+- Please be aware of the tile usage policy.
+
+
+
diff --git a/cookbooks/vectortile/templates/default/nginx.erb b/cookbooks/vectortile/templates/default/nginx.erb
new file mode 100644
index 000000000..866dfc2be
--- /dev/null
+++ b/cookbooks/vectortile/templates/default/nginx.erb
@@ -0,0 +1,40 @@
+server {
+ listen 80 default_server;
+ listen [::]:80 default_server;
+
+ location /nginx_status {
+ stub_status on;
+ access_log off;
+ allow 127.0.0.1;
+ allow ::1;
+ deny all;
+ }
+
+ rewrite ^/\.well-known/acme-challenge/(.*)$ http://acme.openstreetmap.org/.well-known/acme-challenge/$1 permanent;
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+server {
+ # IPv4
+ listen 443 ssl default_server;
+ # IPv6
+ listen [::]:443 ssl default_server;
+ http2 on;
+ server_name localhost;
+
+ ssl_certificate /etc/ssl/certs/<%= node[:fqdn] %>.pem;
+ ssl_certificate_key /etc/ssl/private/<%= node[:fqdn] %>.key;
+
+ location /nginx_status {
+ stub_status on;
+ access_log off;
+ allow 127.0.0.1;
+ allow ::1;
+ deny all;
+ }
+
+ root /srv/vector.openstreetmap.org/html;
+}
diff --git a/cookbooks/vectortile/templates/default/tilekiln-storage-init.erb b/cookbooks/vectortile/templates/default/tilekiln-storage-init.erb
new file mode 100644
index 000000000..fc301398e
--- /dev/null
+++ b/cookbooks/vectortile/templates/default/tilekiln-storage-init.erb
@@ -0,0 +1,6 @@
+#!/bin/sh
+
+# Usage
+# sudo -u tileupdate tilekiln-storage-init
+
+<%= @tilekiln_bin %> storage init --storage-dbname <%= @storage_database %> --config <%= @config_path %>
diff --git a/cookbooks/vectortile/templates/default/tilekiln-storage-update.erb b/cookbooks/vectortile/templates/default/tilekiln-storage-update.erb
new file mode 100644
index 000000000..86f244de3
--- /dev/null
+++ b/cookbooks/vectortile/templates/default/tilekiln-storage-update.erb
@@ -0,0 +1,6 @@
+#!/bin/sh
+
+# Usage
+# sudo -u tilekiln-metadata-update
+
+<%= @tilekiln_bin %> storage init --help
diff --git a/roles/vectortile.rb b/roles/vectortile.rb
new file mode 100644
index 000000000..a5d9f694e
--- /dev/null
+++ b/roles/vectortile.rb
@@ -0,0 +1,72 @@
+name "vectortile"
+description "Role applied to all vector tile servers"
+
+default_attributes(
+ :accounts => {
+ :users => {
+ :pnorman => { :status => :administrator },
+ :tile => {
+ :members => [:jburgess, :tomh, :pnorman]
+ }
+ }
+ },
+ :postgresql => {
+ :settings => {
+ :defaults => {
+ :max_connections => "250",
+ :shared_buffers => "16GB",
+ :work_mem => "128MB",
+ :maintenance_work_mem => "8GB",
+ :max_parallel_workers_per_gather => "0",
+ :wal_level => "minimal",
+ :wal_buffers => "1024kB",
+ :wal_writer_delay => "500ms",
+ :checkpoint_timeout => "60min",
+ :commit_delay => "10000",
+ :max_wal_size => "10GB",
+ :max_wal_senders => "0",
+ :jit => "off",
+ :track_activity_query_size => "16384",
+ :autovacuum_vacuum_scale_factor => "0.05",
+ :autovacuum_analyze_scale_factor => "0.02"
+ }
+ }
+ },
+ :sysctl => {
+ :sockets => {
+ :comment => "Increase size of connection queue",
+ :parameters => {
+ "net.core.somaxconn" => 10000
+ }
+ },
+ :network_conntrack_time_wait => {
+ :comment => "Only track completed connections for 30 seconds",
+ :parameters => {
+ "net.netfilter.nf_conntrack_tcp_timeout_time_wait" => "30"
+ }
+ },
+ :network_conntrack_max => {
+ :comment => "Increase max number of connections tracked",
+ :parameters => {
+ "net.netfilter.nf_conntrack_max" => "524288"
+ }
+ },
+ :no_tcp_slow_start => {
+ :comment => "Disable TCP slow start",
+ :parameters => {
+ "net.ipv4.tcp_slow_start_after_idle" => "0"
+ }
+ },
+ :tcp_use_bbr => {
+ :comment => "Use TCP BBR Congestion Control",
+ :parameters => {
+ "net.core.default_qdisc" => "fq",
+ "net.ipv4.tcp_congestion_control" => "bbr"
+ }
+ }
+ }
+)
+
+run_list(
+ "recipe[vectortile]"
+)
diff --git a/test/data_bags/accounts/tilekiln.json b/test/data_bags/accounts/tilekiln.json
new file mode 100644
index 000000000..100e01771
--- /dev/null
+++ b/test/data_bags/accounts/tilekiln.json
@@ -0,0 +1,6 @@
+{
+ "id": "tilekiln",
+ "uid": "532",
+ "comment": "vectortile.openstreetmap.org",
+ "manage_home": false
+}
diff --git a/test/data_bags/accounts/tileupdate.json b/test/data_bags/accounts/tileupdate.json
new file mode 100644
index 000000000..8b6ba87c6
--- /dev/null
+++ b/test/data_bags/accounts/tileupdate.json
@@ -0,0 +1,6 @@
+{
+ "id": "tileupdate",
+ "uid": "531",
+ "comment": "vectortile.openstreetmap.org",
+ "manage_home": false
+}
diff --git a/test/integration/vectortile/inspec/import_spec.rb b/test/integration/vectortile/inspec/import_spec.rb
new file mode 100644
index 000000000..13ff57e87
--- /dev/null
+++ b/test/integration/vectortile/inspec/import_spec.rb
@@ -0,0 +1,3 @@
+describe file("/usr/local/bin/import-planet") do
+ it { should be_executable.by_user("tileupdate") }
+end
diff --git a/test/integration/vectortile/inspec/nginx_spec.rb b/test/integration/vectortile/inspec/nginx_spec.rb
new file mode 100644
index 000000000..75c1e16b8
--- /dev/null
+++ b/test/integration/vectortile/inspec/nginx_spec.rb
@@ -0,0 +1,26 @@
+describe package("nginx") do
+ it { should be_installed }
+end
+
+describe service("nginx") do
+ it { should be_enabled }
+ it { should be_running }
+end
+
+describe port(80) do
+ it { should be_listening }
+ its("protocols") { should cmp %w[tcp tcp6] }
+end
+
+describe port(443) do
+ it { should be_listening }
+ its("protocols") { should cmp %w[tcp tcp6] }
+end
+
+describe http("http://localhost") do
+ its("status") { should cmp 301 }
+end
+
+describe http("https://localhost", :ssl_verify => false) do
+ its("status") { should cmp 200 }
+end
diff --git a/test/integration/vectortile/inspec/osm2pgsql_spec.rb b/test/integration/vectortile/inspec/osm2pgsql_spec.rb
new file mode 100644
index 000000000..e5efe3340
--- /dev/null
+++ b/test/integration/vectortile/inspec/osm2pgsql_spec.rb
@@ -0,0 +1,4 @@
+describe package("osm2pgsql") do
+ it { should be_installed }
+ its("version") { should cmp >= "1.11.0" }
+end
diff --git a/test/integration/vectortile/inspec/postgresql_spec.rb b/test/integration/vectortile/inspec/postgresql_spec.rb
new file mode 100644
index 000000000..925d6746b
--- /dev/null
+++ b/test/integration/vectortile/inspec/postgresql_spec.rb
@@ -0,0 +1,13 @@
+describe package("postgresql-16") do
+ it { should be_installed }
+end
+
+describe service("postgresql@16-main") do
+ it { should be_enabled }
+ it { should be_running }
+end
+
+describe port(5432) do
+ it { should be_listening }
+ its("protocols") { should cmp %w[tcp tcp6] }
+end
diff --git a/test/integration/vectortile/inspec/storage_spec.rb b/test/integration/vectortile/inspec/storage_spec.rb
new file mode 100644
index 000000000..9498695d1
--- /dev/null
+++ b/test/integration/vectortile/inspec/storage_spec.rb
@@ -0,0 +1,3 @@
+describe file("/usr/local/bin/tilekiln-storage-init") do
+ it { should be_executable.by_user("tileupdate") }
+end
diff --git a/test/integration/vectortile/inspec/themepark_spec.rb b/test/integration/vectortile/inspec/themepark_spec.rb
new file mode 100644
index 000000000..eb7a97013
--- /dev/null
+++ b/test/integration/vectortile/inspec/themepark_spec.rb
@@ -0,0 +1,7 @@
+describe file("/srv/vector.openstreetmap.org/osm2pgsql-themepark/lua/themepark.lua") do
+ it { should exist }
+end
+
+describe file("/srv/vector.openstreetmap.org/spirit/spirit.lua") do
+ it { should exist }
+end
diff --git a/test/integration/vectortile/inspec/tilekiln_spec.rb b/test/integration/vectortile/inspec/tilekiln_spec.rb
new file mode 100644
index 000000000..49ae43e91
--- /dev/null
+++ b/test/integration/vectortile/inspec/tilekiln_spec.rb
@@ -0,0 +1,18 @@
+describe pip("tilekiln", "/opt/tilekiln/bin/pip") do
+ it { should be_installed }
+ its("version") { should cmp >= "0.5.0" }
+end
+
+describe service("tilekiln") do
+ it { should be_enabled }
+ it { should be_running }
+end
+
+describe port(8000) do
+ it { should be_listening }
+ its("protocols") { should cmp %w[tcp] }
+end
+
+describe http("http://localhost:8000") do
+ its("status") { should cmp 404 }
+end