Skip to content

Commit 5d1e21e

Browse files
committed
data correctly persisted now
Signed-off-by: David Kirwan <davidkirwanirl@gmail.com>
1 parent b2c3bbc commit 5d1e21e

10 files changed

Lines changed: 159 additions & 49 deletions

File tree

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ APP_VERSION=dev
1212
# METRICS_SCHEDULER_DISABLED=0
1313

1414
# Optional SQLite persistence for price history (survives restarts)
15+
# Local dev via podman:run uses ./data bind-mounted to /data in the container.
1516
# PRICE_HISTORY_DB_PATH=/data/asset_history.db
16-
# PRICE_HISTORY_RETENTION_DAYS=7
17+
# PRICE_HISTORY_RETENTION_DAYS=365

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,8 @@ Thumbs.db
4040
/vendor/bundle/
4141

4242
# Podman
43-
.containerignore
43+
.containerignore
44+
45+
# Local SQLite price history (see data/.gitkeep)
46+
/data/*
47+
!/data/.gitkeep

Containerfile

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ RUN apk add --no-cache \
1010

1111
WORKDIR /app
1212

13+
ENV BUNDLE_DEPLOYMENT=1 \
14+
BUNDLE_WITHOUT="development:test" \
15+
BUNDLE_PATH=/usr/local/bundle \
16+
GEM_HOME=/usr/local/bundle
17+
1318
# Copy dependency files
1419
COPY Gemfile Gemfile.lock ./
1520

16-
# Install gems
17-
RUN bundle config set --local deployment 'true' \
18-
&& bundle config set --local without 'development test' \
19-
&& bundle install --jobs=4 --retry=3
21+
# Install gems (clean binstubs; no host vendor/bundle)
22+
RUN bundle install --jobs=4 --retry=3
2023

2124
# Production stage
2225
FROM ruby:3.4.8-alpine AS production
@@ -26,34 +29,33 @@ RUN apk add --no-cache \
2629
tzdata \
2730
ca-certificates \
2831
sqlite \
32+
wget \
2933
&& addgroup -g 1001 -S appgroup \
3034
&& adduser -u 1001 -S appuser -G appgroup
3135

32-
# Set timezone
33-
ENV TZ=UTC
36+
ENV TZ=UTC \
37+
RACK_ENV=production \
38+
PORT=8080 \
39+
BUNDLE_DEPLOYMENT=1 \
40+
BUNDLE_WITHOUT="development:test" \
41+
BUNDLE_PATH=/usr/local/bundle \
42+
GEM_HOME=/usr/local/bundle
3443

35-
# Create app directory
3644
WORKDIR /app
3745

3846
# Copy gems from builder stage
3947
COPY --from=builder /usr/local/bundle /usr/local/bundle
4048

41-
# Copy application code
42-
COPY --chown=appuser:appgroup . .
49+
# Copy application code (vendor/bundle excluded via .containerignore)
50+
COPY --chown=appuser:appgroup Gemfile Gemfile.lock config.ru ./
51+
COPY --chown=appuser:appgroup lib/ lib/
52+
COPY --chown=appuser:appgroup views/ views/
4353

44-
# Create non-root user and set permissions
4554
USER appuser
4655

47-
# Health check
4856
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
4957
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
5058

51-
# Expose port
5259
EXPOSE 8080
5360

54-
# Set environment variables
55-
ENV RACK_ENV=production
56-
ENV PORT=8080
57-
58-
# Start the application
59-
CMD ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "8080"]
61+
CMD ["bundle", "exec", "rackup", "config.ru", "--host", "0.0.0.0", "-p", "8080"]

Makefile

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ APP_HOST ?= 0.0.0.0
99
APP_PORT ?= 8080
1010
CONTAINERFILE ?= Containerfile
1111
IMAGE ?= asset-monitoring:latest
12+
DATA_DIR ?= $(CURDIR)/data
13+
CONTAINER_DATA_DIR ?= /data
14+
PRICE_HISTORY_DB_PATH ?= $(CONTAINER_DATA_DIR)/asset_history.db
15+
PRICE_HISTORY_RETENTION_DAYS ?= 365
1216
BUNDLE ?= bundle
1317
BUNDLE_EXEC ?= $(BUNDLE) exec
1418
RACKUP := $(BUNDLE_EXEC) rackup config.ru --host $(APP_HOST) -p $(APP_PORT)
1519

20+
# RVM binstubs call ruby_executable_hooks; keep GEM_HOME/bin on PATH for make recipes.
21+
export PATH := $(shell ruby -e 'paths=[]; paths << File.join(ENV["GEM_HOME"], "bin") if ENV["GEM_HOME"]; print paths.join(":") + (paths.empty? ? "" : ":")'):$(PATH)
22+
1623
.PHONY: help install server dev console \
1724
spec test rubocop lint check coverage \
1825
security audit brakeman \
@@ -61,8 +68,13 @@ security: audit brakeman ## Run security checks (bundle-audit + brakeman)
6168
podman-build: ## Build the container image tagged asset-monitoring:latest
6269
podman build -t $(IMAGE) -f $(CONTAINERFILE) .
6370

64-
podman-run: ## Run the container (port $(APP_PORT))
65-
podman run -p $(APP_PORT):$(APP_PORT) $(IMAGE)
71+
podman-run: ## Run the container (SQLite in ./data bind-mounted to /data)
72+
@mkdir -p $(DATA_DIR)
73+
podman run --rm -p $(APP_PORT):$(APP_PORT) \
74+
-e PRICE_HISTORY_RETENTION_DAYS=$(PRICE_HISTORY_RETENTION_DAYS) \
75+
-e PRICE_HISTORY_DB_PATH=$(PRICE_HISTORY_DB_PATH) \
76+
-v $(DATA_DIR):$(CONTAINER_DATA_DIR):Z,U \
77+
$(IMAGE)
6678

6779
podman_build: podman-build ## Alias for podman-build
6880

Rakefile

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,33 @@
11
# frozen_string_literal: true
22

33
require 'bundler/setup'
4+
require 'fileutils'
45

56
require 'rspec/core/rake_task'
67
require 'rubocop/rake_task'
78

9+
# RVM (and similar) gem binstubs use `ruby_executable_hooks`; ensure it is on PATH
10+
# when Rake spawns subshells via `sh`.
11+
def ensure_gem_exec_path!
12+
paths = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR)
13+
[ENV['GEM_HOME'], Gem.dir].compact.filter_map do |base|
14+
File.join(base, 'bin') if base && !base.empty?
15+
end.each do |bin|
16+
paths.unshift(bin) unless paths.include?(bin)
17+
end
18+
ENV['PATH'] = paths.join(File::PATH_SEPARATOR)
19+
end
20+
21+
ensure_gem_exec_path!
22+
823
APP_HOST = ENV.fetch('HOST', '0.0.0.0')
924
APP_PORT = ENV.fetch('PORT', '8080')
1025
CONTAINERFILE = 'Containerfile'
1126
IMAGE = 'asset-monitoring:latest'
12-
RACKUP_CMD = "bundle exec rackup config.ru --host #{APP_HOST} -p #{APP_PORT}"
27+
DATA_DIR = File.expand_path(ENV.fetch('DATA_DIR', 'data'), __dir__)
28+
CONTAINER_DATA_DIR = '/data'
29+
PRICE_HISTORY_DB_PATH = File.join(CONTAINER_DATA_DIR, 'asset_history.db')
30+
RACKUP_CMD = ['bundle', 'exec', 'rackup', 'config.ru', '--host', APP_HOST, '-p', APP_PORT]
1331

1432
RSpec::Core::RakeTask.new(:spec)
1533
RuboCop::RakeTask.new(:rubocop)
@@ -33,12 +51,12 @@ end
3351
namespace :security do
3452
desc 'Run Bundler audit'
3553
task :audit do
36-
sh 'bundle exec bundle-audit check --update'
54+
sh 'bundle', 'exec', 'bundle-audit', 'check', '--update'
3755
end
3856

3957
desc 'Run Brakeman'
4058
task :brakeman do
41-
sh 'bundle exec brakeman --no-pager'
59+
sh 'bundle', 'exec', 'brakeman', '--no-pager'
4260
end
4361
end
4462

@@ -53,34 +71,45 @@ task brakeman: 'security:brakeman'
5371

5472
desc 'Install dependencies'
5573
task :install do
56-
sh 'bundle install'
74+
sh 'bundle', 'install'
5775
end
5876

5977
desc 'Start the application server'
6078
task :server do
61-
sh RACKUP_CMD
79+
sh(*RACKUP_CMD)
6280
end
6381

6482
desc 'Start the application server with auto-reload (development)'
6583
task :dev do
66-
sh "bundle exec rerun -- #{RACKUP_CMD}"
84+
sh 'bundle', 'exec', 'rerun', '--', *RACKUP_CMD
6785
end
6886

6987
desc 'Start an interactive console'
7088
task :console do
7189
ENV['METRICS_SCHEDULER_DISABLED'] = '1'
72-
sh 'bundle exec pry -Ilib -r asset_monitoring'
90+
sh 'bundle', 'exec', 'pry', '-Ilib', '-r', 'asset_monitoring'
7391
end
7492

7593
namespace :podman do
7694
desc 'Build the container image'
7795
task :build do
78-
sh "podman build -t #{IMAGE} -f #{CONTAINERFILE} ."
96+
sh 'podman', 'build', '-t', IMAGE, '-f', CONTAINERFILE, '.'
7997
end
8098

81-
desc 'Run the container'
99+
desc 'Run the container (1-year price history, SQLite in ./data bind-mounted to /data)'
82100
task :run do
83-
sh "podman run -p #{APP_PORT}:#{APP_PORT} #{IMAGE}"
101+
retention_days = ENV.fetch('PRICE_HISTORY_RETENTION_DAYS', '365')
102+
db_path = ENV.fetch('PRICE_HISTORY_DB_PATH', PRICE_HISTORY_DB_PATH)
103+
container_data_dir = File.dirname(db_path)
104+
105+
FileUtils.mkdir_p(DATA_DIR)
106+
107+
sh 'podman', 'run', '--rm',
108+
'-p', "#{APP_PORT}:#{APP_PORT}",
109+
'-e', "PRICE_HISTORY_RETENTION_DAYS=#{retention_days}",
110+
'-e', "PRICE_HISTORY_DB_PATH=#{db_path}",
111+
'-v', "#{DATA_DIR}:#{container_data_dir}:Z,U",
112+
IMAGE
84113
end
85114
end
86115

data/.gitkeep

Whitespace-only changes.

lib/price_history.rb

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,16 @@ def configure_from_env!
2727
end
2828

2929
log = resolve_logger
30-
@store = PriceHistoryStore.new(db_path, retention_days: @retention_days, log: log)
31-
hydrate_from_store! if @store&.enabled?
32-
end
33-
34-
private_class_method def resolve_retention_days
35-
val = ENV.fetch('PRICE_HISTORY_RETENTION_DAYS', DEFAULT_RETENTION_DAYS.to_s).to_i
36-
val.positive? ? val : DEFAULT_RETENTION_DAYS
37-
end
38-
39-
private_class_method def resolve_logger
40-
return nil unless defined?(Asset::Monitoring)
41-
42-
settings = Asset::Monitoring.settings
43-
return nil unless settings.respond_to?(:log)
30+
store = PriceHistoryStore.new(db_path, retention_days: @retention_days, log: log)
31+
unless store.enabled?
32+
log_warn("SQLite price history disabled; could not open #{db_path} (check directory permissions)")
33+
@store = nil
34+
return
35+
end
4436

45-
settings.log
37+
@store = store
38+
log_info("SQLite price history enabled at #{db_path} (retention: #{@retention_days} days)")
39+
hydrate_from_store!
4640
end
4741

4842
attr_reader :retention_days
@@ -95,6 +89,38 @@ def to_api_hash
9589

9690
private
9791

92+
def resolve_retention_days
93+
val = ENV.fetch('PRICE_HISTORY_RETENTION_DAYS', DEFAULT_RETENTION_DAYS.to_s).to_i
94+
val.positive? ? val : DEFAULT_RETENTION_DAYS
95+
end
96+
97+
def resolve_logger
98+
return nil unless defined?(Asset::Monitoring)
99+
100+
settings = Asset::Monitoring.settings
101+
return nil unless settings.respond_to?(:log)
102+
103+
settings.log
104+
end
105+
106+
def log_info(message)
107+
logger = resolve_logger
108+
if logger.respond_to?(:info)
109+
logger.info(message)
110+
else
111+
warn "[PriceHistory] #{message}"
112+
end
113+
end
114+
115+
def log_warn(message)
116+
logger = resolve_logger
117+
if logger.respond_to?(:warn)
118+
logger.warn(message)
119+
else
120+
warn "[PriceHistory] #{message}"
121+
end
122+
end
123+
98124
def retention_seconds
99125
@retention_days * 24 * 60 * 60
100126
end

lib/price_history_store.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ def initialize(db_path, retention_days:, log: nil)
2121
ensure_directory
2222
open_database
2323
ensure_schema
24+
log_info("Opened SQLite store at #{@db_path}")
2425
rescue StandardError => e
25-
log_error("Failed to initialize SQLite store at #{@db_path}: #{e.message}")
26+
log_error("Failed to initialize SQLite store at #{@db_path}: #{e.class}: #{e.message}")
2627
close_database
2728
@db = nil
2829
end
@@ -125,6 +126,11 @@ def open_database
125126
@db.busy_timeout = 5_000
126127
@db.execute('PRAGMA foreign_keys = ON')
127128
@db.execute('PRAGMA journal_mode = DELETE') # simple and safe default
129+
130+
return if @db_path == ':memory:'
131+
return if File.file?(@db_path)
132+
133+
raise Errno::ENOENT, "SQLite database file was not created at #{@db_path}"
128134
end
129135

130136
def ensure_schema
@@ -172,6 +178,14 @@ def log_error(message)
172178
warn "[PriceHistoryStore] #{message}"
173179
end
174180
end
181+
182+
def log_info(message)
183+
if @log.respond_to?(:info)
184+
@log.info(message)
185+
else
186+
warn "[PriceHistoryStore] #{message}"
187+
end
188+
end
175189
end
176190
# rubocop:enable Metrics/ClassLength
177191
end

spec/price_history_store_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@
8282
File.unlink(temp_db) if File.exist?(temp_db)
8383
end
8484

85+
it 'creates the database file on disk during initialization' do
86+
dir = Dir.mktmpdir
87+
db_path = File.join(dir, 'asset_history.db')
88+
expect(File.exist?(db_path)).to be false
89+
90+
store = described_class.new(db_path, retention_days: 30, log: log)
91+
92+
expect(store.enabled?).to be true
93+
expect(File.exist?(db_path)).to be true
94+
expect(File.size(db_path)).to be > 0
95+
ensure
96+
FileUtils.remove_entry(dir) if defined?(dir) && dir && Dir.exist?(dir)
97+
end
98+
8599
it 'persists across separate store instances (simulating restart)' do
86100
now = Time.now.to_i
87101
store1 = described_class.new(temp_db, retention_days: 30, log: log)

tasks/help.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,15 @@ Security:
2424

2525
Podman:
2626
rake podman:build / make podman-build - Build the container image
27-
rake podman:run / make podman-run - Run the container
27+
rake podman:run / make podman-run - Run the container (365-day retention, SQLite in ./data)
28+
29+
podman:run defaults (override via env):
30+
PRICE_HISTORY_RETENTION_DAYS=365 - Retain price history for one year
31+
PRICE_HISTORY_DB_PATH=/data/asset_history.db - Path inside the container
32+
DATA_DIR=./data - Host directory bind-mounted to /data
33+
34+
The SQLite file appears in ./data/asset_history.db after a successful start.
35+
Check container logs for "SQLite price history enabled" or permission errors.
2836

2937
Environment overrides (server/dev):
3038
HOST=0.0.0.0 PORT=8080 - Bind address and port (defaults shown)

0 commit comments

Comments
 (0)