Adopt XDG base directories for app data and config (#316)

* Support XDG base directories

* Keep Docker MESH_DB on persistent volume
This commit is contained in:
l5y
2025-10-13 12:29:56 +02:00
committed by GitHub
parent 5133e9d498
commit 9c73fceea7
7 changed files with 450 additions and 6 deletions

View File

@@ -53,9 +53,9 @@ COPY --chown=potatomesh:potatomesh web/views/ ./views/
COPY --chown=potatomesh:potatomesh data/*.sql /data/
# Create data and configuration directories with correct ownership
RUN mkdir -p /app/data \
&& mkdir -p /app/.config/well-known \
&& chown -R potatomesh:potatomesh /app/data /app/.config
RUN mkdir -p /app/.local/share/potato-mesh \
&& mkdir -p /app/.config/potato-mesh/well-known \
&& chown -R potatomesh:potatomesh /app/.local/share /app/.config
# Switch to non-root user
USER potatomesh
@@ -66,6 +66,8 @@ EXPOSE 41447
# Default environment variables (can be overridden by host)
ENV RACK_ENV=production \
APP_ENV=production \
XDG_DATA_HOME=/app/.local/share \
XDG_CONFIG_HOME=/app/.config \
MESH_DB=/app/data/mesh.db \
DB_BUSY_TIMEOUT_MS=5000 \
DB_BUSY_MAX_RETRIES=5 \

View File

@@ -47,6 +47,7 @@ require_relative "application/federation"
require_relative "application/prometheus"
require_relative "application/queries"
require_relative "application/data_processing"
require_relative "application/filesystem"
require_relative "application/routes/api"
require_relative "application/routes/ingest"
require_relative "application/routes/root"
@@ -61,6 +62,7 @@ module PotatoMesh
extend App::Prometheus
extend App::Queries
extend App::DataProcessing
extend App::Filesystem
helpers App::Helpers
include App::Database
@@ -70,6 +72,7 @@ module PotatoMesh
include App::Prometheus
include App::Queries
include App::DataProcessing
include App::Filesystem
register App::Routes::Api
register App::Routes::Ingest
@@ -119,6 +122,7 @@ module PotatoMesh
apply_logger_level!
perform_initial_filesystem_setup!
cleanup_legacy_well_known_artifacts
init_db unless db_schema_present?
ensure_schema_upgrades

View File

@@ -0,0 +1,99 @@
# 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
#
# http://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.
# frozen_string_literal: true
require "fileutils"
module PotatoMesh
module App
# Filesystem helpers responsible for migrating legacy assets to XDG compliant
# directories and preparing runtime storage locations.
module Filesystem
# Execute all filesystem migrations required before the application boots.
#
# @return [void]
def perform_initial_filesystem_setup!
migrate_legacy_database!
migrate_legacy_keyfile!
end
private
# Copy the legacy database file into the configured XDG data directory.
#
# @return [void]
def migrate_legacy_database!
return unless default_database_destination?
migrate_legacy_file(
PotatoMesh::Config.legacy_db_path,
PotatoMesh::Config.db_path,
chmod: 0o600,
context: "filesystem.db",
)
end
# Copy the legacy keyfile into the configured XDG configuration directory.
#
# @return [void]
def migrate_legacy_keyfile!
migrate_legacy_file(
PotatoMesh::Config.legacy_keyfile_path,
PotatoMesh::Config.keyfile_path,
chmod: 0o600,
context: "filesystem.keys",
)
end
# Migrate a legacy file if it exists and the destination has not been created yet.
#
# @param source_path [String] absolute path to the legacy file.
# @param destination_path [String] absolute path to the new file location.
# @param chmod [Integer, nil] optional permission bits applied to the destination file.
# @param context [String] logging context describing the migration target.
# @return [void]
def migrate_legacy_file(source_path, destination_path, chmod:, context:)
return if source_path == destination_path
return unless File.exist?(source_path)
return if File.exist?(destination_path)
FileUtils.mkdir_p(File.dirname(destination_path))
FileUtils.cp(source_path, destination_path)
File.chmod(chmod, destination_path) if chmod
debug_log(
"Migrated legacy file to XDG directory",
context: context,
source: source_path,
destination: destination_path,
)
rescue SystemCallError => e
warn_log(
"Failed to migrate legacy file",
context: context,
source: source_path,
destination: destination_path,
error_class: e.class.name,
error_message: e.message,
)
end
# Determine whether the database destination matches the configured default.
#
# @return [Boolean] true when the destination should receive migrated data.
def default_database_destination?
PotatoMesh::Config.db_path == PotatoMesh::Config.default_db_path
end
end
end
end

View File

@@ -32,10 +32,31 @@ module PotatoMesh
@repo_root ||= File.expand_path("..", web_root)
end
# Resolve the current XDG data directory for PotatoMesh content.
#
# @return [String] absolute path to the PotatoMesh data directory.
def data_directory
File.join(resolve_xdg_home("XDG_DATA_HOME", %w[.local share]), "potato-mesh")
end
# Resolve the current XDG configuration directory for PotatoMesh files.
#
# @return [String] absolute path to the PotatoMesh configuration directory.
def config_directory
File.join(resolve_xdg_home("XDG_CONFIG_HOME", %w[.config]), "potato-mesh")
end
# Build the default SQLite database path inside the data directory.
#
# @return [String] absolute path to +data/mesh.db+.
# @return [String] absolute path to the managed +mesh.db+ file.
def default_db_path
File.join(data_directory, "mesh.db")
end
# Legacy database path bundled alongside the repository.
#
# @return [String] absolute path to the repository managed database file.
def legacy_db_path
File.expand_path("../data/mesh.db", web_root)
end
@@ -166,7 +187,7 @@ module PotatoMesh
#
# @return [String] absolute location of the PEM file.
def keyfile_path
File.join(web_root, ".config", "keyfile")
File.join(config_directory, "keyfile")
end
# Sub-path used when exposing well known configuration files.
@@ -180,7 +201,21 @@ module PotatoMesh
#
# @return [String] absolute storage path.
def well_known_storage_root
File.join(web_root, ".config", "well-known")
File.join(config_directory, "well-known")
end
# Legacy configuration directory bundled with the repository.
#
# @return [String] absolute path to the repository managed configuration directory.
def legacy_config_directory
File.join(web_root, ".config")
end
# Legacy keyfile location used before introducing XDG directories.
#
# @return [String] absolute filesystem path to the legacy keyfile.
def legacy_keyfile_path
File.join(legacy_config_directory, "keyfile")
end
# Legacy location for well known assets within the public folder.
@@ -308,5 +343,31 @@ module PotatoMesh
trimmed = value.strip
trimmed.empty? ? default : trimmed
end
# Resolve the effective XDG directory honoring environment overrides.
#
# @param env_key [String] name of the environment variable to inspect.
# @param fallback_segments [Array<String>] path segments appended to the user home directory.
# @return [String] absolute base directory referenced by the XDG variable.
def resolve_xdg_home(env_key, fallback_segments)
raw = fetch_string(env_key, nil)
candidate = raw && !raw.empty? ? raw : nil
return File.expand_path(candidate) if candidate
base_home = safe_home_directory
File.expand_path(File.join(base_home, *fallback_segments))
end
# Retrieve the current user's home directory handling runtime failures.
#
# @return [String] absolute path to the user home or web root fallback.
def safe_home_directory
home = Dir.home
return web_root if home.nil? || home.empty?
home
rescue ArgumentError, RuntimeError
web_root
end
end
end

View File

@@ -15,6 +15,91 @@
require "spec_helper"
RSpec.describe PotatoMesh::Config do
describe ".data_directory" do
it "uses the configured XDG data home when provided" do
Dir.mktmpdir do |dir|
data_home = File.join(dir, "xdg-data")
within_env("XDG_DATA_HOME" => data_home) do
expect(described_class.data_directory).to eq(File.join(data_home, "potato-mesh"))
end
end
end
it "falls back to the user home directory" do
within_env("XDG_DATA_HOME" => nil) do
allow(Dir).to receive(:home).and_return("/home/spec")
expect(described_class.data_directory).to eq("/home/spec/.local/share/potato-mesh")
end
ensure
allow(Dir).to receive(:home).and_call_original
end
it "falls back to the web root when the home directory is unavailable" do
within_env("XDG_DATA_HOME" => nil) do
allow(Dir).to receive(:home).and_raise(ArgumentError)
expected = File.join(described_class.web_root, ".local", "share", "potato-mesh")
expect(described_class.data_directory).to eq(expected)
end
ensure
allow(Dir).to receive(:home).and_call_original
end
it "falls back to the web root when the home directory is nil" do
within_env("XDG_DATA_HOME" => nil) do
allow(Dir).to receive(:home).and_return(nil)
expected = File.join(described_class.web_root, ".local", "share", "potato-mesh")
expect(described_class.data_directory).to eq(expected)
end
ensure
allow(Dir).to receive(:home).and_call_original
end
end
describe ".config_directory" do
it "uses the configured XDG config home when provided" do
Dir.mktmpdir do |dir|
config_home = File.join(dir, "xdg-config")
within_env("XDG_CONFIG_HOME" => config_home) do
expect(described_class.config_directory).to eq(File.join(config_home, "potato-mesh"))
end
end
end
it "falls back to the web root when the home directory is empty" do
within_env("XDG_CONFIG_HOME" => nil) do
allow(Dir).to receive(:home).and_return("")
expected = File.join(described_class.web_root, ".config", "potato-mesh")
expect(described_class.config_directory).to eq(expected)
end
ensure
allow(Dir).to receive(:home).and_call_original
end
end
describe ".legacy_config_directory" do
it "returns the repository managed configuration directory" do
expect(described_class.legacy_config_directory).to eq(
File.join(described_class.web_root, ".config"),
)
end
end
describe ".legacy_keyfile_path" do
it "returns the legacy keyfile location" do
expect(described_class.legacy_keyfile_path).to eq(
File.join(described_class.web_root, ".config", "keyfile"),
)
end
end
describe ".legacy_db_path" do
it "returns the bundled database location" do
expect(described_class.legacy_db_path).to eq(
File.expand_path("../data/mesh.db", described_class.web_root),
)
end
end
describe ".federation_announcement_interval" do
it "returns eight hours in seconds" do
expect(described_class.federation_announcement_interval).to eq(8 * 60 * 60)

188
web/spec/filesystem_spec.rb Normal file
View File

@@ -0,0 +1,188 @@
# 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
#
# http://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.
# frozen_string_literal: true
require "spec_helper"
RSpec.describe PotatoMesh::App::Filesystem do
let(:harness_class) do
Class.new do
extend PotatoMesh::App::Filesystem
class << self
def debug_entries
@debug_entries ||= []
end
def warning_entries
@warning_entries ||= []
end
def debug_log(message, context:, **metadata)
debug_entries << { message: message, context: context, metadata: metadata }
end
def warn_log(message, context:, **metadata)
warning_entries << { message: message, context: context, metadata: metadata }
end
def reset_logs!
@debug_entries = []
@warning_entries = []
end
end
end
end
around do |example|
harness_class.reset_logs!
example.run
harness_class.reset_logs!
end
describe "#perform_initial_filesystem_setup!" do
it "migrates the legacy database and keyfile" do
Dir.mktmpdir do |dir|
legacy_db = File.join(dir, "legacy", "mesh.db")
legacy_key = File.join(dir, "legacy-config", "keyfile")
new_db = File.join(dir, "data", "potato-mesh", "mesh.db")
new_key = File.join(dir, "config", "potato-mesh", "keyfile")
FileUtils.mkdir_p(File.dirname(legacy_db))
File.write(legacy_db, "db")
FileUtils.mkdir_p(File.dirname(legacy_key))
File.write(legacy_key, "key")
allow(PotatoMesh::Config).to receive_messages(
legacy_db_path: legacy_db,
db_path: new_db,
default_db_path: new_db,
legacy_keyfile_path: legacy_key,
keyfile_path: new_key,
)
harness_class.perform_initial_filesystem_setup!
expect(File).to exist(new_db)
expect(File).to exist(new_key)
expect(File.read(new_db)).to eq("db")
expect(File.read(new_key)).to eq("key")
expect(File.stat(new_key).mode & 0o777).to eq(0o600)
expect(File.stat(new_db).mode & 0o777).to eq(0o600)
expect(harness_class.debug_entries.size).to eq(2)
expect(harness_class.warning_entries).to be_empty
end
end
it "skips database migration when using a custom destination" do
Dir.mktmpdir do |dir|
legacy_db = File.join(dir, "legacy", "mesh.db")
new_db = File.join(dir, "custom", "database.db")
FileUtils.mkdir_p(File.dirname(legacy_db))
File.write(legacy_db, "db")
allow(PotatoMesh::Config).to receive_messages(
legacy_db_path: legacy_db,
db_path: new_db,
default_db_path: File.join(dir, "default", "mesh.db"),
legacy_keyfile_path: File.join(dir, "old", "keyfile"),
keyfile_path: File.join(dir, "config", "keyfile"),
)
harness_class.perform_initial_filesystem_setup!
expect(File).not_to exist(new_db)
end
end
end
describe "private migration helpers" do
it "does not migrate when the source is missing" do
Dir.mktmpdir do |dir|
destination = File.join(dir, "target", "file")
harness_class.send(
:migrate_legacy_file,
File.join(dir, "missing"),
destination,
chmod: 0o600,
context: "spec.context",
)
expect(File).not_to exist(destination)
expect(harness_class.debug_entries).to be_empty
end
end
it "does not overwrite existing destinations" do
Dir.mktmpdir do |dir|
source = File.join(dir, "source")
destination = File.join(dir, "destination")
File.write(source, "alpha")
FileUtils.mkdir_p(File.dirname(destination))
File.write(destination, "beta")
harness_class.send(
:migrate_legacy_file,
source,
destination,
chmod: 0o600,
context: "spec.context",
)
expect(File.read(destination)).to eq("beta")
end
end
it "ignores migrations when the source and destination are identical" do
Dir.mktmpdir do |dir|
path = File.join(dir, "shared")
File.write(path, "same")
harness_class.send(
:migrate_legacy_file,
path,
path,
chmod: 0o600,
context: "spec.context",
)
expect(harness_class.debug_entries).to be_empty
end
end
it "logs warnings when the migration fails" do
Dir.mktmpdir do |dir|
source = File.join(dir, "source")
destination = File.join(dir, "destination")
File.write(source, "data")
allow(FileUtils).to receive(:mkdir_p).and_raise(Errno::EACCES)
harness_class.send(
:migrate_legacy_file,
source,
destination,
chmod: 0o600,
context: "spec.context",
)
expect(harness_class.warning_entries.size).to eq(1)
expect(harness_class.debug_entries).to be_empty
end
ensure
allow(FileUtils).to receive(:mkdir_p).and_call_original
end
end
end

View File

@@ -37,6 +37,11 @@ ENV["RACK_ENV"] = "test"
SPEC_TMPDIR = Dir.mktmpdir("potato-mesh-spec-")
ENV["MESH_DB"] = File.join(SPEC_TMPDIR, "mesh.db")
ENV["XDG_DATA_HOME"] = File.join(SPEC_TMPDIR, "xdg-data")
ENV["XDG_CONFIG_HOME"] = File.join(SPEC_TMPDIR, "xdg-config")
FileUtils.mkdir_p(ENV["XDG_DATA_HOME"])
FileUtils.mkdir_p(ENV["XDG_CONFIG_HOME"])
require_relative "../app"