From 00b8a1852799472730763510a100418b1b09a234 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 19 Aug 2025 21:28:22 +0200 Subject: [PATCH] feat(health): migrate VERSION file to merkwerk integration (#83) - Replace read_version() with merkwerk.get_health_info() - Health endpoint now returns content_hash, vcs_info, source tracking - Add merkwerk_integrated feature flag - Enhanced startup logs with content-hash and VCS info - Maintain backward compatibility with version field - lua51 compatible integration for OpenBSD deployment Migration from static VERSION file to dynamic merkwerk version tracking. Health endpoint now provides rich metadata for debugging and monitoring. Resolves DAW/furt#83 --- .version_history | 3 + integrations/lua-api.lua | 243 +++++++++++++++++++++++++++++++++++++++ src/main.lua | 48 +++++--- 3 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 .version_history create mode 100644 integrations/lua-api.lua diff --git a/.version_history b/.version_history new file mode 100644 index 0000000..4a48ffe --- /dev/null +++ b/.version_history @@ -0,0 +1,3 @@ +# merkwerk version history +# Format: content_hash,vcs_hash,branch,timestamp,author,vcs_type,project_type +7e82f537,7053af3,main,2025-08-19T18:14:06Z,michael,git,lua-api diff --git a/integrations/lua-api.lua b/integrations/lua-api.lua new file mode 100644 index 0000000..cd96832 --- /dev/null +++ b/integrations/lua-api.lua @@ -0,0 +1,243 @@ +-- integrations/lua-api.lua - merkwerk Lua API integration +-- Provides merkwerk version information for Lua applications + +local merkwerk = {} + +-- Cache for version info to avoid repeated shell calls +local cache = { + data = nil, + timestamp = 0, + ttl = 300 -- 5 minutes default TTL +} + +-- Execute merkwerk command and return result +local function execute_merkwerk(args) + args = args or "info --json" + local command = "./tools/merkwerk/bin/merkwerk " .. args .. " 2>/dev/null" + + local handle = io.popen(command) + if not handle then + return nil, "Failed to execute merkwerk command" + end + + local result = handle:read("*a") + local success, exit_reason, exit_code = handle:close() + + if not success or (exit_code and exit_code ~= 0) then + return nil, "merkwerk command failed with exit code " .. (exit_code or "unknown") + end + + if result == "" then + return nil, "merkwerk returned empty result" + end + + return result, nil +end + +-- Parse JSON response (simple parser for basic merkwerk JSON) +local function parse_json_response(json_str) + if not json_str then return nil end + + -- Try to use cjson if available + local ok, cjson = pcall(require, "cjson") + if ok then + local success, data = pcall(cjson.decode, json_str) + if success then return data end + end + + -- Fallback: simple manual parsing for merkwerk JSON structure + local data = {} + + -- Extract basic fields using pattern matching + data.project_name = json_str:match('"project_name"%s*:%s*"([^"]*)"') or "unknown" + data.project_type = json_str:match('"project_type"%s*:%s*"([^"]*)"') or "unknown" + data.base_version = json_str:match('"base_version"%s*:%s*"([^"]*)"') or "?.?.?" + data.content_hash = json_str:match('"content_hash"%s*:%s*"([^"]*)"') or "unknown" + data.full_version = json_str:match('"full_version"%s*:%s*"([^"]*)"') or "?.?.?+unknown" + data.timestamp = json_str:match('"timestamp"%s*:%s*"([^"]*)"') or "" + + -- Extract VCS info + local vcs_block = json_str:match('"vcs"%s*:%s*{([^}]*)}') + if vcs_block then + data.vcs = {} + data.vcs.type = vcs_block:match('"type"%s*:%s*"([^"]*)"') or "none" + data.vcs.hash = vcs_block:match('"hash"%s*:%s*"([^"]*)"') or "" + data.vcs.branch = vcs_block:match('"branch"%s*:%s*"([^"]*)"') or "" + else + data.vcs = { type = "none", hash = "", branch = "" } + end + + return data +end + +-- Generate fallback info when merkwerk is unavailable +local function fallback_info() + return { + project_name = "unknown", + project_type = "lua-api", + base_version = "?.?.?", + content_hash = "unknown", + full_version = "?.?.?+unknown", + timestamp = os.date("%Y-%m-%dT%H:%M:%SZ"), + vcs = { + type = "none", + hash = "", + branch = "" + }, + source = "fallback", + error = "merkwerk not available" + } +end + +-- Check if cached data is still valid +local function is_cache_valid(ttl) + ttl = ttl or cache.ttl + local current_time = os.time() + return cache.data and (current_time - cache.timestamp) < ttl +end + +-- Get version information with caching +function merkwerk.get_info(options) + options = options or {} + local use_cache = options.cache ~= false + local cache_ttl = options.cache_ttl or cache.ttl + local fallback_version = options.fallback_version + local include_build_info = options.include_build_info or false + + -- Return cached data if valid and caching enabled + if use_cache and is_cache_valid(cache_ttl) then + local result = cache.data + if include_build_info then + result.build_info = { + cached = true, + cache_age = os.time() - cache.timestamp, + cache_ttl = cache_ttl + } + end + return result + end + + -- Execute merkwerk command + local json_result, error_msg = execute_merkwerk("info --json") + + if not json_result then + -- merkwerk failed - use fallback + local fallback = fallback_info() + if fallback_version then + fallback.base_version = fallback_version + fallback.full_version = fallback_version .. "+unknown" + end + if error_msg then + fallback.error = error_msg + end + + if include_build_info then + fallback.build_info = { + cached = false, + error = error_msg or "merkwerk unavailable" + } + end + + return fallback + end + + -- Parse JSON response + local data = parse_json_response(json_result) + if not data then + -- JSON parsing failed - use fallback + local fallback = fallback_info() + fallback.error = "Failed to parse merkwerk JSON output" + + if include_build_info then + fallback.build_info = { + cached = false, + error = "JSON parsing failed", + raw_output = json_result:sub(1, 100) -- First 100 chars for debugging + } + end + + return fallback + end + + -- Add metadata + data.source = "merkwerk" + + if include_build_info then + data.build_info = { + cached = false, + timestamp = os.time(), + cache_ttl = cache_ttl + } + end + + -- Update cache + if use_cache then + cache.data = data + cache.timestamp = os.time() + end + + return data +end + +-- Get only the content hash (lightweight) +function merkwerk.get_hash() + local hash_result, error_msg = execute_merkwerk("hash") + + if not hash_result then + return "unknown" + end + + -- Clean up the result (remove whitespace) + return hash_result:gsub("%s+", "") +end + +-- Get version for HTTP health endpoints +function merkwerk.get_health_info() + local info = merkwerk.get_info({ cache = true, cache_ttl = 600 }) -- 10 minute cache for health checks + + return { + service = info.project_name, + version = info.full_version, + content_hash = info.content_hash, + vcs_info = info.vcs, + timestamp = info.timestamp, + source = info.source + } +end + +-- Get minimal version string for logging +function merkwerk.get_version_string() + local info = merkwerk.get_info({ cache = true }) + return info.full_version +end + +-- Clear cache (useful for testing or forced refresh) +function merkwerk.clear_cache() + cache.data = nil + cache.timestamp = 0 +end + +-- Set cache TTL +function merkwerk.set_cache_ttl(ttl) + cache.ttl = ttl or 300 +end + +-- Get cache status (for debugging) +function merkwerk.get_cache_status() + return { + has_data = cache.data ~= nil, + timestamp = cache.timestamp, + age = cache.data and (os.time() - cache.timestamp) or 0, + ttl = cache.ttl, + valid = is_cache_valid() + } +end + +-- Validate merkwerk availability +function merkwerk.validate() + local result, error_msg = execute_merkwerk("info") + return result ~= nil, error_msg +end + +return merkwerk + diff --git a/src/main.lua b/src/main.lua index 3345cf4..eee08ef 100644 --- a/src/main.lua +++ b/src/main.lua @@ -13,23 +13,29 @@ local AuthRoute = require("src.routes.auth") -- Load configuration local config = require("config.server") --- Read version from VERSION file -local function read_version() - local file, err = io.open("VERSION", "r") - if not file then - print("WARNING: Could not read VERSION file: " .. (err or "unknown error")) - return "?.?.?" +local function get_version_info() + -- Load merkwerk integration + local success, merkwerk = pcall(require, "integrations.lua-api") + if not success then + print("WARNING: merkwerk integration not available, using fallback") + return { + service = "furt-lua", + version = "?.?.?", + content_hash = "unknown", + vcs_info = { type = "none", hash = "", branch = "" }, + source = "fallback-no-merkwerk" + } end - local version = file:read("*line") - file:close() + -- Get merkwerk health info + local health_info = merkwerk.get_health_info() - if not version or version:match("^%s*$") then - print("WARNING: VERSION file is empty or contains only whitespace") - return "?.?.?" + -- Ensure compatibility with old VERSION-only expectations + if not health_info.version then + health_info.version = "?.?.?" end - return version:match("^%s*(.-)%s*$") -- trim whitespace + return health_info end -- HTTP-Server Module @@ -243,10 +249,12 @@ function FurtServer:start() error("Failed to bind to " .. self.host .. ":" .. self.port) end - local version = read_version() + local version_info = get_version_info() print(string.format("Furt HTTP-Server started on %s:%d", self.host, self.port)) - print("Version: " .. version) + print("Version: " .. version_info.version .. " (merkwerk)") + print("Content-Hash: " .. (version_info.content_hash or "unknown")) + print("VCS: " .. (version_info.vcs_info and version_info.vcs_info.hash or "none")) print("API-Key authentication: ENABLED") print("Rate limiting: ENABLED (60 req/hour per API key, 100 req/hour per IP)") print("CORS enabled for configured origins") @@ -267,16 +275,20 @@ local server = FurtServer:new() -- Public routes (no authentication required) server:add_route("GET", "/health", function(request, server) - local version = read_version() + local version_info = get_version_info() local response_data = { status = "healthy", - service = "furt-lua", - version = version, + service = version_info.service or "furt-lua", + version = version_info.version, + content_hash = version_info.content_hash, + vcs_info = version_info.vcs_info, timestamp = os.time(), + source = version_info.source, features = { smtp_configured = config.mail and config.mail.username ~= nil, auth_enabled = true, - rate_limiting = true + rate_limiting = true, + merkwerk_integrated = version_info.source == "merkwerk" } } return server:create_response(200, response_data, nil, nil, request)