diff --git a/src/configs/bigbox-v1-cm-2.json b/src/configs/bigbox-v1-cm-2.json index c95dfe87..f1c9e6b1 100644 --- a/src/configs/bigbox-v1-cm-2.json +++ b/src/configs/bigbox-v1-cm-2.json @@ -1582,6 +1582,10 @@ } }, "pipelines": { + "component_update_lifecycle": { + "protocol": "http", + "process": [] + }, "wann_type": { "template": "any_change" }, diff --git a/src/services/metrics.lua b/src/services/metrics.lua index fef32663..60ef6889 100644 --- a/src/services/metrics.lua +++ b/src/services/metrics.lua @@ -343,9 +343,9 @@ local function handle_metric(msg) if not metric_name then return end local pipe_cfg = State.pipelines_map[metric_name] + local payload = msg.payload if not pipe_cfg then return end -- no matching pipeline, drop silently - local payload = msg.payload if type(payload) ~= 'table' then return end local value = payload.value diff --git a/src/services/update/component_watch.lua b/src/services/update/component_watch.lua index 2ada84fb..18833333 100644 --- a/src/services/update/component_watch.lua +++ b/src/services/update/component_watch.lua @@ -8,6 +8,7 @@ local fibers = require 'fibers' local scoped_work = require 'devicecode.support.scoped_work' local bus_cleanup = require 'devicecode.support.bus_cleanup' local topics = require 'services.update.topics' +local queue = require 'devicecode.support.queue' local M = {} @@ -45,9 +46,22 @@ local function open_watches(scope, conn, components, queue_len) return watches, nil end +local function report_component_fact(params, component, ev) + if not params.events_tx then + return true, nil + end + return queue.try_admit_required(params.events_tx, { + kind = 'component_fact_changed', + component = component, + payload = ev.payload, + origin = ev.origin, + }, 'update_component_fact_changed_admission_failed') +end + local function watch_loop(scope, params) local observer = assert(params.observer, 'component_watch observer required') - local watches, err = open_watches(scope, assert(params.conn, 'component_watch conn required'), params.components, params.queue_len) + local conn = assert(params.conn, 'component_watch conn required') + local watches, err = open_watches(scope, conn, params.components, params.queue_len) if not watches then error(err or 'component_watch_open_failed', 0) end while true do @@ -62,7 +76,9 @@ local function watch_loop(scope, params) return { role = 'update_component_watch', reason = recv_err or 'component_watch_closed' } end if ev.op == 'retain' or ev.event == 'retain' or ev.type == 'retain' or ev.kind == 'retain' then - observer:update_component(component, ev.payload, ev.origin) + local ok_update, update_err = observer:update_component(component, ev.payload, ev.origin) + if ok_update == nil then error(update_err or 'component_observer_update_failed', 0) end + report_component_fact(params, component, ev) elseif ev.op == 'unretain' or ev.event == 'unretain' or ev.type == 'unretain' or ev.kind == 'unretain' then observer:remove_component(component, 'component_unretained') end @@ -87,6 +103,7 @@ function M.start(scope, params) observer = params.observer, components = components, queue_len = params.queue_len, + events_tx = params.events_tx, }) end, report = params.report, diff --git a/src/services/update/lifecycle_metrics.lua b/src/services/update/lifecycle_metrics.lua new file mode 100644 index 00000000..5e1ef295 --- /dev/null +++ b/src/services/update/lifecycle_metrics.lua @@ -0,0 +1,108 @@ +-- services/update/lifecycle_metrics.lua +-- +-- Small publisher helper for component update lifecycle metrics. The Update +-- service derives lifecycle records from Device component facts, then calls +-- emit() with the phase it wants to publish. + +local M = {} + +M.METRIC_NAME = 'component_update_lifecycle' + +local METRIC_TOPIC = { 'obs', 'v1', 'update', 'metric', M.METRIC_NAME } + +-- Metric payload shape consumed by services.metrics: +-- { +-- value = 'started' | 'staged' | 'completed' | 'failed' | 'cancelled', +-- namespace = { '', 'lifecycle', '', '' }, +-- job_id = '', component = '', ... +-- } +-- The namespace is later used as a metrics topic suffix, so each segment must +-- avoid separators, whitespace, and dashes. + +---Return a string safe to use as one metric namespace segment. +---This is a topic/namespace normaliser, not a security boundary: it preserves +---letters, digits, and '_', replaces every other run with '_', trims edge +---underscores, and falls back to 'unknown' if nothing usable remains. +---@param value any Original component or job identifier. +---@return string token Namespace-safe token. +local function safe_token(value) + local s = tostring(value or '') + s = s:gsub('[^%w_]', '_') + s = s:gsub('_+', '_') + s = s:gsub('^_+', ''):gsub('_+$', '') + if s == '' then return 'unknown' end + return s +end + +---@param record table|nil Lifecycle record built from Device component facts. +---@return string|nil component Component id when present and non-empty. +local function component_of(record) + local component = type(record) == 'table' and record.component or nil + if type(component) ~= 'string' or component == '' then return nil end + return component +end + +---Build the metric payload expected by services.metrics. +---@param record table Lifecycle record with job_id/component/state/error fields. +---@param phase string Lifecycle phase to publish. +---@param extra table|nil Additional local-observability fields. +---@return table|nil payload Metric payload, or nil on invalid input. +---@return string|nil err Error code when payload is nil. +local function payload(record, phase, extra) + local component = component_of(record) + if not component then return nil, 'component_required' end + if type(phase) ~= 'string' or phase == '' then return nil, 'phase_required' end + + local out = { + value = phase, + namespace = { safe_token(component), 'lifecycle', safe_token(record.job_id), safe_token(phase) }, + job_id = record.job_id, + component = component, + state = record.state, + error = record.error, + } + for k, v in pairs(extra or {}) do + out[k] = v + end + return out, nil +end + +---@param conn table|nil Bus connection fallback, used when no service_base object exists. +---@param svc table|nil service_base-like object with obs_metric(). +---@param p table Metric payload from payload(). +---@return boolean|nil ok True when published. +---@return string|nil err Error code when no sink is available. +local function publish(conn, svc, p) + if svc and type(svc.obs_metric) == 'function' then + svc:obs_metric(M.METRIC_NAME, p) + return true, nil + end + + if conn and type(conn.retain) == 'function' then + conn:retain(METRIC_TOPIC, p) + return true, nil + end + + return nil, 'metric_sink_unavailable' +end + +---@param value any +---@return string +function M.safe_token(value) + return safe_token(value) +end + +---@param conn table|nil +---@param svc table|nil +---@param record table Lifecycle record derived from a Device component fact. +---@param phase string +---@param extra table|nil +---@return boolean|nil ok +---@return string|nil err +function M.emit(conn, svc, record, phase, extra) + local p, err = payload(record, phase, extra) + if not p then return nil, err end + return publish(conn, svc, p) +end + +return M diff --git a/src/services/update/service.lua b/src/services/update/service.lua index 6192b672..c4d2a29e 100644 --- a/src/services/update/service.lua +++ b/src/services/update/service.lua @@ -33,6 +33,7 @@ local component_watch = require 'services.update.component_watch' local artifact_store_bus = require 'services.update.artifacts.store_bus' local component_backend_mod = require 'services.update.backends.component' local router_backend_mod = require 'services.update.backends.router' +local lifecycle_metrics = require 'services.update.lifecycle_metrics' local M = {} @@ -135,6 +136,183 @@ local function update_model_state(self, state, reason) end end + +local function updater_from_fact(fact) + if type(fact) ~= 'table' then return nil end + return type(fact.updater) == 'table' and fact.updater + or type(fact.update) == 'table' and fact.update + or nil +end + +local function software_from_fact(fact) + return type(fact) == 'table' and type(fact.software) == 'table' and fact.software or nil +end + +local function meaningful_fact_value(v) + if v == nil then return nil end + if type(v) == 'userdata' then return nil end + if type(v) == 'string' and v == '' then return nil end + return v +end + +local function updater_job_id(upd) + local job_id = type(upd) == 'table' and upd.job_id or nil + if type(job_id) ~= 'string' or job_id == '' then return nil end + return job_id +end + +local function updater_error(upd) + if type(upd) ~= 'table' then return nil end + return meaningful_fact_value(upd.last_error) or meaningful_fact_value(upd.error) +end + +local function expected_image_id(job) + if type(job) ~= 'table' then return nil end + local meta = type(job.metadata) == 'table' and job.metadata or {} + local art = type(job.artifact) == 'table' and job.artifact or nil + local stage = type(job.stage_result) == 'table' and job.stage_result + or type(job.staged) == 'table' and job.staged + or type(job.stage) == 'table' and job.stage + or nil + local preflight = type(stage) == 'table' and type(stage.preflight) == 'table' and stage.preflight or nil + local pre_commit = type(job.commit_attempt) == 'table' and type(job.commit_attempt.pre_commit) == 'table' + and job.commit_attempt.pre_commit or nil + return job.expected_image_id + or meta.expected_image_id + or meta.image_id + or (type(stage) == 'table' and (stage.expected_image_id or stage.image_id)) + or (preflight and (preflight.expected_image_id or preflight.image_id)) + or (pre_commit and pre_commit.expected_image_id) + or (art and (art.expected_image_id or art.image_id)) +end + +local function lifecycle_record(component, fact) + local upd = updater_from_fact(fact) or {} + return { + job_id = updater_job_id(upd), + component = component or (type(fact) == 'table' and fact.component), + state = upd.state, + error = updater_error(upd), + } +end + +local function lifecycle_record_from_job(job) + if type(job) ~= 'table' then return nil end + return { + job_id = job.job_id, + component = job.component, + state = job.state, + error = job.error, + } +end + +local function lifecycle_metric_key(record, phase) + return table.concat({ + tostring(record and record.component or 'unknown'), + tostring(record and record.job_id or 'unknown'), + tostring(phase or 'unknown'), + }, '/') +end + +local function lifecycle_metric_sent(self, record, phase) + self._component_lifecycle_sent = self._component_lifecycle_sent or {} + return self._component_lifecycle_sent[lifecycle_metric_key(record, phase)] == true +end + +local function mark_lifecycle_metric_sent(self, record, phase, sent) + self._component_lifecycle_sent = self._component_lifecycle_sent or {} + self._component_lifecycle_sent[lifecycle_metric_key(record, phase)] = sent == true or nil +end + +local function emit_component_lifecycle_metric(self, record, phase, extra) + if lifecycle_metric_sent(self, record, phase) then + return true, nil + end + mark_lifecycle_metric_sent(self, record, phase, true) + + local ok, err = lifecycle_metrics.emit(self._conn, self._svc, record, phase, extra) + if ok ~= true then + mark_lifecycle_metric_sent(self, record, phase, false) + return true, nil + end + return true, nil +end + +local function job_lifecycle_phase(job) + if type(job) ~= 'table' then return nil, 'job_missing' end + if type(job.job_id) ~= 'string' or job.job_id == '' then return nil, 'job_id_missing' end + if type(job.component) ~= 'string' or job.component == '' then return nil, 'component_missing' end + + local state = job.state + if state == 'created' then return nil, 'job_not_started' end + if state == 'succeeded' then return 'completed', 'job_succeeded' end + if state == 'failed' or state == 'timed_out' then return 'failed', 'job_failed' end + if state == 'cancelled' or state == 'discarded' or state == 'superseded' then + return 'cancelled', 'job_cancelled' + end + if state == 'awaiting_commit' then return 'staged', 'job_staged' end + if state == 'staging' or state == 'committing' or state == 'awaiting_return' then + return 'started', 'job_active' + end + return nil, 'job_state_unmapped' +end + +local function emit_job_lifecycle_metric(self, job, reason) + local phase, phase_reason = job_lifecycle_phase(job) + local record = lifecycle_record_from_job(job) + if not phase then return true, nil end + + return emit_component_lifecycle_metric(self, record, phase, { + source = 'update_job_state', + reason = phase_reason, + source_reason = reason, + }) +end + +local function emit_job_lifecycle_metrics_from_snapshot(self, snapshot, reason) + local jobs = snapshot and snapshot.jobs and snapshot.jobs.by_id or nil + if type(jobs) ~= 'table' then return true, nil end + for _, job in pairs(jobs) do + local ok, err = emit_job_lifecycle_metric(self, job, reason) + if ok ~= true then return ok, err end + end + return true, nil +end + +local function component_fact_phase(component, fact, self) + local upd = updater_from_fact(fact) or {} + local job_id = updater_job_id(upd) + if not job_id then return nil, 'job_id_missing' end + if upd.state == 'cancelled' or upd.state == 'aborted' then return 'cancelled', 'updater_cancelled' end + if upd.state == 'failed' or upd.state == 'rollback_detected' or updater_error(upd) ~= nil then + return 'failed', 'updater_failed' + end + + local record = lifecycle_record(component, fact) + if lifecycle_metric_sent(self, record, 'started') then + local job = self._jobs and self._jobs:get(job_id) or nil + local expected = expected_image_id(job) + local sw = software_from_fact(fact) + if type(sw) == 'table' and expected and sw.image_id == expected then + return 'completed', 'expected_image_seen' + end + end + + return 'started', 'job_observed' +end + +local function emit_component_fact_lifecycle_metric(self, ev) + local component = ev and ev.component or nil + local fact = ev and ev.payload or nil + local phase = component_fact_phase(component, fact, self) + if not phase then return true, nil end + local record = lifecycle_record(component, fact) + return emit_component_lifecycle_metric(self, record, phase, { + source = 'device_component_fact', + reason = phase, + }) +end + local function apply_generation_snapshot(self, snapshot) self._model:update(function (s) s.generation = snapshot.generation or s.generation @@ -556,6 +734,7 @@ local function handle_job_runtime_changed(self, ev) if self._jobs and self._jobs:ready() and not self._job_runtime_ready then self._job_runtime_ready = true update_service_jobs_projection(self) + emit_job_lifecycle_metrics_from_snapshot(self, ev.snapshot, 'job_runtime_ready') local ok, err = reconcile_runtime_components(self, 'job_runtime_ready') if ok ~= true then update_model_state(self, 'failed', err or 'runtime_dependents_start_failed') @@ -563,6 +742,7 @@ local function handle_job_runtime_changed(self, ev) end else update_service_jobs_projection(self) + emit_job_lifecycle_metrics_from_snapshot(self, ev.snapshot, 'job_runtime_changed') end if self._current_generation then apply_generation_snapshot(self, self._current_generation.last_snapshot or { @@ -674,6 +854,11 @@ local function reduce_event(self, ev) return end + if ev.kind == 'component_fact_changed' then + emit_component_fact_lifecycle_metric(self, ev) + return + end + if ev.kind == 'active_runtime_changed' then handle_active_runtime_changed(self, ev) return @@ -1168,6 +1353,7 @@ local function ensure_component_watch(self, reason) config = self._config, queue_len = params.component_watch_queue_len, report = service_events.reporter(cw_port, 'update_component_watch_completion_report_failed'), + events_tx = self._done_tx, }) if not cwh then return nil, cwerr or 'update_component_watch_start_failed' end self._component_watch = cwh @@ -1368,6 +1554,7 @@ function M.run(scope, params) local self = setmetatable({ _scope = scope, + _conn = params.conn, _service_id = service_id, _svc = params.svc, _model = service_model, @@ -1398,6 +1585,7 @@ function M.run(scope, params) _component_observer = component_observer, _component_watch = nil, _backend = backend, + _component_lifecycle_sent = {}, _job_runtime_ready = false, _generation_done_queue_len = params.generation_done_queue_len, _manager_route_queue_len = params.manager_route_queue_len, diff --git a/tests/integration/devhost/metrics_spec.lua b/tests/integration/devhost/metrics_spec.lua index 669b97ec..1d882a96 100644 --- a/tests/integration/devhost/metrics_spec.lua +++ b/tests/integration/devhost/metrics_spec.lua @@ -525,4 +525,41 @@ function T.per_endpoint_state_isolation() end, { timeout = 3.0 }) end + +function T.component_update_lifecycle_metric_uses_event_namespace() + runfibers.run(function(scope) + local clock = new_test_clock() + scope:finally(function() clock:restore() end) + local bus = make_bus() + local test_conn = bus:connect() + + test_conn:retain({ "cap", "fs", "credentials", "state" }, "added") + start_mock_hal(test_conn, scope) + test_conn:retain({ "state", "time", "synced" }, true) + + local result_sub = test_conn:subscribe( + { "obs", "v1", "metrics", "output", "#" }, + { queue_len = 10, full = "drop_oldest" }) + + test_conn:retain({ "cfg", "metrics" }, bus_pipeline_config("component_update_lifecycle", 0.1)) + local svc_scope = start_metrics(bus, scope) + flush_ticks() + + test_conn:publish( + { "obs", "v1", "update", "metric", "component_update_lifecycle" }, + { value = "started", namespace = { "mcu", "lifecycle", "job_1", "started" } }) + + local msg = recv_metric(clock, result_sub, 0.5) + assert(msg ~= nil, "expected lifecycle metric publish") + assert(table.concat(msg.topic, ".") == "obs.v1.metrics.output.mcu.lifecycle.job_1.started", + "unexpected topic " .. table.concat(msg.topic, ".")) + assert(msg.payload.value == "started", + "expected value=started, got " .. tostring(msg.payload.value)) + + stop_scope(svc_scope) + clock:restore() + end, { timeout = 3.0 }) +end + + return T diff --git a/tests/run.lua b/tests/run.lua index c6ee9e24..f32ec02d 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -83,6 +83,7 @@ local files = { 'unit.update.test_generation_refactor', 'unit.update.test_ingest_artifacts', 'unit.update.test_job_repository', + 'unit.update.test_lifecycle_metrics', 'unit.update.test_job_runtime', 'unit.update.test_job_store_memory', 'unit.update.test_job_store_control_store', diff --git a/tests/unit/metrics/config_spec.lua b/tests/unit/metrics/config_spec.lua index bc25bd53..83175966 100644 --- a/tests/unit/metrics/config_spec.lua +++ b/tests/unit/metrics/config_spec.lua @@ -137,4 +137,24 @@ function T.validate_config_propagates_invalid_template_to_pipeline() assert(saw_metric_invalid_protocol, "expected warning: sim inherited invalid protocol 'invalid'") end +local function read_project_file(rel) + local candidates = { rel, '../' .. rel } + for i = 1, #candidates do + local f = io.open(candidates[i], 'rb') + if f then local data = f:read('*a'); f:close(); return data end + end + return nil, 'unable to read ' .. rel +end + +function T.bigbox_config_accepts_component_update_lifecycle_pipeline() + local cjson = require 'cjson.safe' + local text = assert(read_project_file('src/configs/bigbox-v1-cm-2.json')) + local doc = assert(cjson.decode(text)) + local metrics = assert(doc.metrics) + local ok, _, err = conf.validate_config(metrics) + assert(ok == true, 'expected bigbox metrics config to validate: ' .. tostring(err)) + assert(metrics.data.pipelines.component_update_lifecycle.protocol == 'http', + 'expected component_update_lifecycle to use HTTP pipeline') +end + return T diff --git a/tests/unit/update/test_lifecycle_metrics.lua b/tests/unit/update/test_lifecycle_metrics.lua new file mode 100644 index 00000000..df3d4ba8 --- /dev/null +++ b/tests/unit/update/test_lifecycle_metrics.lua @@ -0,0 +1,83 @@ +-- tests/unit/update/test_lifecycle_metrics.lua + +local metrics = require 'services.update.lifecycle_metrics' + +local tests = {} + +local function fail(msg) error(msg or 'assertion failed', 2) end +local function assert_eq(a, b, msg) + if a ~= b then + fail(msg or ('expected ' .. tostring(b) .. ', got ' .. tostring(a))) + end +end +local function assert_not_nil(v, msg) if v == nil then fail(msg or 'expected non-nil value') end end +local function assert_nil(v, msg) if v ~= nil then fail(msg or ('expected nil, got ' .. tostring(v))) end end + +function tests.test_safe_token_replaces_namespace_unsafe_characters() + assert_eq(metrics.safe_token('job/mcu.1 ready'), 'job_mcu_1_ready') + assert_eq(metrics.safe_token('job-1_ok'), 'job_1_ok') + assert_eq(metrics.safe_token('///'), 'unknown') +end + +function tests.test_emit_publishes_component_lifecycle_payload() + local seen + local svc = { + obs_metric = function (_, name, payload) + seen = { name = name, payload = payload } + end, + } + + local ok, err = metrics.emit(nil, svc, { + job_id = 'job/mcu.1', + component = 'mcu', + state = 'ready', + error = nil, + }, 'started', { source = 'device_component_fact' }) + + assert_eq(ok, true, err) + assert_not_nil(seen, 'expected obs_metric call') + assert_eq(seen.name, 'component_update_lifecycle') + assert_eq(seen.payload.value, 'started') + assert_eq(table.concat(seen.payload.namespace, '.'), 'mcu.lifecycle.job_mcu_1.started') + assert_eq(seen.payload.job_id, 'job/mcu.1') + assert_eq(seen.payload.component, 'mcu') + assert_eq(seen.payload.state, 'ready') + assert_eq(seen.payload.source, 'device_component_fact') +end + +function tests.test_emit_sanitizes_full_uuid_job_id_in_namespace() + local seen + local svc = { + obs_metric = function (_, name, payload) + seen = { name = name, payload = payload } + end, + } + + local job_id = '08be26e5-e0b6-4f11-9c64-d1053e983820' + local ok, err = metrics.emit(nil, svc, { + job_id = job_id, + component = 'mcu', + state = 'ready', + }, 'started') + + assert_eq(ok, true, err) + assert_eq(seen.payload.job_id, job_id) + assert_eq(table.concat(seen.payload.namespace, '.'), 'mcu.lifecycle.08be26e5_e0b6_4f11_9c64_d1053e983820.started') +end + +function tests.test_emit_rejects_missing_component_or_phase() + local ok_component, err_component = metrics.emit(nil, {}, { + job_id = 'j1', + }, 'started') + assert_nil(ok_component) + assert_eq(err_component, 'component_required') + + local ok_phase, err_phase = metrics.emit(nil, {}, { + job_id = 'j1', + component = 'mcu', + }, '') + assert_nil(ok_phase) + assert_eq(err_phase, 'phase_required') +end + +return tests diff --git a/tests/unit/update/test_service_phase2.lua b/tests/unit/update/test_service_phase2.lua index b6d26c36..97c566c6 100644 --- a/tests/unit/update/test_service_phase2.lua +++ b/tests/unit/update/test_service_phase2.lua @@ -514,4 +514,316 @@ function tests.test_artifact_store_dependency_gates_generation_after_job_store_r end) end + + +local function lifecycle_metric_topic() + return { "obs", "v1", "update", "metric", "component_update_lifecycle" } +end + +local function component_config(component) + return { + schema = "devicecode.update/1", + components = { { component = component or "mcu" } }, + } +end + +local function retain_component_fact(conn, component, opts) + opts = opts or {} + conn:retain(topics.device_component(component), { + component = component, + software = { + image_id = opts.image_id, + boot_id = opts.boot_id, + }, + updater = { + state = opts.updater_state or "ready", + job_id = opts.job_id, + last_error = opts.last_error, + }, + }) +end + +local function wait_lifecycle_metric(caller, value) + local metric_view = caller:retained_view(lifecycle_metric_topic()) + local metric + local ok = probe.wait_until(function () + local msg = metric_view:get(lifecycle_metric_topic()) + metric = msg and msg.payload or nil + return metric and metric.value == value + end, { timeout = 0.8, interval = 0.01 }) + metric_view:close() + assert_true(ok, "expected " .. tostring(value) .. " lifecycle metric") + return metric +end + +function tests.test_component_device_fact_job_id_emits_lifecycle_started_metric() + fibers.run(function (root_scope) + local child, caller = start_service(root_scope, { + config = component_config("mcu"), + }) + + retain_component_fact(caller, "mcu", { + job_id = "job/mcu.1", + image_id = "image-old", + boot_id = "boot-1", + }) + + local metric = wait_lifecycle_metric(caller, "started") + assert_eq(table.concat(metric.namespace, "."), "mcu.lifecycle.job_mcu_1.started") + assert_eq(metric.job_id, "job/mcu.1") + assert_eq(metric.component, "mcu") + assert_eq(metric.source, "device_component_fact") + + child:cancel("test complete") + end) +end + +function tests.test_component_device_fact_json_null_error_still_emits_lifecycle_started_metric() + fibers.run(function (root_scope) + local child, caller = start_service(root_scope, { + config = component_config("mcu"), + }) + + retain_component_fact(caller, "mcu", { + job_id = "job/mcu.null-error", + updater_state = "ready", + last_error = newproxy(false), + image_id = "image-old", + boot_id = "boot-1", + }) + + local metric = wait_lifecycle_metric(caller, "started") + assert_eq(table.concat(metric.namespace, "."), "mcu.lifecycle.job_mcu_null_error.started") + assert_eq(metric.job_id, "job/mcu.null-error") + assert_eq(metric.error, nil) + + child:cancel("test complete") + end) +end + +function tests.test_component_device_fact_expected_image_emits_lifecycle_completed_metric() + fibers.run(function (root_scope) + local child, caller = start_service(root_scope, { + config = component_config("mcu"), + initial_jobs = { + jobs = { + ["j-success"] = { + job_id = "j-success", + component = "mcu", + artifact_ref = "artifact-mcu", + expected_image_id = "image-new", + state = "staging", + created_seq = 1, + updated_seq = 1, + }, + }, + }, + }) + + retain_component_fact(caller, "mcu", { + job_id = "j-success", + image_id = "image-old", + boot_id = "boot-1", + }) + wait_lifecycle_metric(caller, "started") + + retain_component_fact(caller, "mcu", { + job_id = "j-success", + image_id = "image-new", + boot_id = "boot-2", + }) + + local metric = wait_lifecycle_metric(caller, "completed") + assert_eq(table.concat(metric.namespace, "."), "mcu.lifecycle.j_success.completed") + assert_eq(metric.state, "ready") + + child:cancel("test complete") + end) +end + +function tests.test_component_device_fact_error_emits_lifecycle_failed_metric() + fibers.run(function (root_scope) + local child, caller = start_service(root_scope, { + config = component_config("mcu"), + }) + + retain_component_fact(caller, "mcu", { + job_id = "j-fail", + updater_state = "failed", + last_error = "wrong_image_after_reboot", + image_id = "image-old", + boot_id = "boot-1", + }) + + local metric = wait_lifecycle_metric(caller, "failed") + assert_eq(table.concat(metric.namespace, "."), "mcu.lifecycle.j_fail.failed") + assert_eq(metric.state, "failed") + assert_eq(metric.error, "wrong_image_after_reboot") + + child:cancel("test complete") + end) +end + +function tests.test_component_device_fact_cancelled_emits_lifecycle_cancelled_metric() + fibers.run(function (root_scope) + local child, caller = start_service(root_scope, { + config = component_config("mcu"), + }) + + retain_component_fact(caller, "mcu", { + job_id = "j-cancel", + updater_state = "cancelled", + image_id = "image-old", + boot_id = "boot-1", + }) + + local metric = wait_lifecycle_metric(caller, "cancelled") + assert_eq(table.concat(metric.namespace, "."), "mcu.lifecycle.j_cancel.cancelled") + assert_eq(metric.state, "cancelled") + + child:cancel("test complete") + end) +end + +function tests.test_other_component_device_fact_emits_component_lifecycle_metric() + fibers.run(function (root_scope) + local child, caller = start_service(root_scope, { + config = component_config("cm5"), + }) + + assert(caller:call(topics.update_manager_rpc("create-job"), { + job_id = "j-cm5", + component = "cm5", + artifact_ref = "artifact-cm5", + }, { timeout = 0.5 })) + + retain_component_fact(caller, "cm5", { + job_id = "j-cm5", + image_id = "cm5-old", + boot_id = "boot-1", + }) + + local metric = wait_lifecycle_metric(caller, "started") + assert_eq(table.concat(metric.namespace, "."), "cm5.lifecycle.j_cm5.started") + assert_eq(metric.component, "cm5") + + child:cancel("test complete") + end) +end + +function tests.test_job_state_awaiting_commit_emits_lifecycle_staged_metric_without_component_fact_job_id() + fibers.run(function (root_scope) + local backend = {} + function backend:stage_op(job) + return op.always({ job_id = job.job_id, expected_image_id = "image-new" }, nil) + end + + local child, caller = start_service(root_scope, { + config = component_config("cm5"), + backend = backend, + }) + + assert(caller:call(topics.update_manager_rpc("create-job"), { + job_id = "j-state-staged", + component = "cm5", + artifact_ref = "artifact-cm5", + expected_image_id = "image-new", + }, { timeout = 0.5 })) + assert(caller:call(topics.update_manager_rpc("start-job"), { + job_id = "j-state-staged", + }, { timeout = 0.5 })) + + local metric = wait_lifecycle_metric(caller, "staged") + assert_eq(table.concat(metric.namespace, "."), "cm5.lifecycle.j_state_staged.staged") + assert_eq(metric.job_id, "j-state-staged") + assert_eq(metric.source, "update_job_state") + + child:cancel("test complete") + end) +end + +function tests.test_job_state_failure_emits_lifecycle_failed_metric_without_component_fact_job_id() + fibers.run(function (root_scope) + local backend = {} + function backend:stage_op(_job) + return op.always(nil, "no_session") + end + + local child, caller = start_service(root_scope, { + config = component_config("cm5"), + backend = backend, + }) + + assert(caller:call(topics.update_manager_rpc("create-job"), { + job_id = "j-state-fail", + component = "cm5", + artifact_ref = "artifact-cm5", + expected_image_id = "image-new", + }, { timeout = 0.5 })) + assert(caller:call(topics.update_manager_rpc("start-job"), { + job_id = "j-state-fail", + }, { timeout = 0.5 })) + + local metric = wait_lifecycle_metric(caller, "failed") + assert_eq(table.concat(metric.namespace, "."), "cm5.lifecycle.j_state_fail.failed") + assert_eq(metric.job_id, "j-state-fail") + assert_eq(metric.component, "cm5") + assert_eq(metric.source, "update_job_state") + assert_eq(metric.error, "no_session") + + child:cancel("test complete") + end) +end + +function tests.test_job_state_reconcile_success_emits_lifecycle_completed_metric() + fibers.run(function (root_scope) + local backend = {} + function backend:stage_op(job) + return op.always({ job_id = job.job_id, expected_image_id = "image-new" }, nil) + end + function backend:commit_capabilities() + return { policy = "idempotent_by_token" } + end + function backend:commit_op(job, ctx) + return op.always({ accepted = true, token = ctx.commit_token, job_id = job.job_id }, nil) + end + function backend:evaluate_reconcile() + return { done = true, tag = "reconciled_success", observed = { ok = true } } + end + + local child, caller = start_service(root_scope, { + config = component_config("cm5"), + backend = backend, + }) + + assert(caller:call(topics.update_manager_rpc("create-job"), { + job_id = "j-state-complete", + component = "cm5", + artifact_ref = "artifact-cm5", + expected_image_id = "image-new", + }, { timeout = 0.5 })) + assert(caller:call(topics.update_manager_rpc("start-job"), { + job_id = "j-state-complete", + }, { timeout = 0.5 })) + assert_true(probe.wait_until(function () + local status = caller:call(topics.update_manager_rpc("status"), {}, { timeout = 0.05 }) + return status and status.snapshot.jobs.by_id["j-state-complete"] + and status.snapshot.jobs.by_id["j-state-complete"].state == "awaiting_commit" + end, { timeout = 0.8, interval = 0.01 }), "expected awaiting_commit") + + assert(caller:call(topics.update_manager_rpc("commit-job"), { + job_id = "j-state-complete", + }, { timeout = 0.5 })) + + local metric = wait_lifecycle_metric(caller, "completed") + assert_eq(table.concat(metric.namespace, "."), "cm5.lifecycle.j_state_complete.completed") + assert_eq(metric.job_id, "j-state-complete") + assert_eq(metric.component, "cm5") + assert_eq(metric.source, "update_job_state") + + child:cancel("test complete") + end) +end + + return tests