mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
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:
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
99
web/lib/potato_mesh/application/filesystem.rb
Normal file
99
web/lib/potato_mesh/application/filesystem.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
188
web/spec/filesystem_spec.rb
Normal 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
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user