Skip to content

Commit e7cb757

Browse files
committed
feat(ota_plugin): write manifest fragments + notify gateway on install/uninstall
Without this, OTA-installed apps (obstacle_classifier_v2 after the Install flow) showed up in the gateway runtime graph but never got attached to a manifest entity, so the Foxglove tree treated them as orphans and they never appeared under the turtlebot3 component or in any function's host list. The catalog said "this update adds the obstacle_classifier app", the runtime saw the new node, but the manifest tree stayed unchanged. Wire the gateway's plugin-side manifest fragment contract: 1. Plugin reads `fragments_dir` from its config (matches the gateway's discovery.manifest.fragments_dir param). Empty = legacy "no fragments" behavior preserved. 2. set_context() now stores the PluginContext so post-execute we can call notify_entities_changed and have the gateway re-merge the base manifest with the fragments dir. 3. On Install: render a minimal manifest YAML for the new app (id from added_components[0], node_name from x_medkit_executable, located on the single turtlebot3 component), atomic-publish via tmp-rename per the ManifestManager fragment contract, then notify. Gateway picks the entity up; new app shows under /components/turtlebot3/hosts and any function that lists it. 4. On Uninstall: drop the fragment file (no-op if the entity was in the base manifest, like broken_lidar_legacy). Notify either way so the cache stops returning a now-dead app. 5. On Update: no fragment change (same app id, just different binary), but still notify so the entity cache rebuilds with the new pid. Adds the dirs to Dockerfile and threads fragments_dir through both gateway_config.yaml (discovery.manifest.fragments_dir) and the plugin config block (plugins.ota_update_plugin.fragments_dir) so they stay in lockstep.
1 parent af571b4 commit e7cb757

4 files changed

Lines changed: 157 additions & 4 deletions

File tree

demos/ota_nav2_sensor_fix/Dockerfile.gateway

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
9494
COPY --from=builder /ws/install /ws/install
9595
COPY gateway_config.yaml /etc/ros2_medkit/gateway_config.yaml
9696
COPY manifest.yaml /etc/ros2_medkit/manifest.yaml
97+
98+
# Pre-create the fragments directory so the gateway's manifest manager
99+
# scans an existing (empty) dir at boot rather than logging "missing
100+
# fragments_dir" warnings. Plugin writes / removes yaml files here at
101+
# OTA install / uninstall time.
102+
RUN mkdir -p /etc/ros2_medkit/manifest_fragments
97103
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
98104
RUN chmod +x /usr/local/bin/entrypoint.sh
99105

demos/ota_nav2_sensor_fix/gateway_config.yaml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,18 @@ ros2_medkit_gateway:
2626

2727
discovery:
2828
# Hybrid: manifest defines areas/components/apps/functions, runtime
29-
# fills in topics/services/params and surfaces OTA-installed nodes
30-
# (e.g. obstacle_classifier_v2 after trigger-install.sh) without
31-
# needing a manifest entry for them.
29+
# fills in topics/services/params, and OTA-deployed apps land via
30+
# manifest fragments dropped in fragments_dir below.
3231
mode: "hybrid"
3332
manifest_path: "/etc/ros2_medkit/manifest.yaml"
3433
manifest_strict_validation: false
34+
manifest:
35+
# ota_update_plugin writes one fragment per Install operation
36+
# here and calls notify_entities_changed; the gateway re-merges
37+
# the base manifest + every yaml in this dir on each reload.
38+
# Path is shared with the plugin via plugins.ota_update_plugin
39+
# .fragments_dir below - keep them in lockstep.
40+
fragments_dir: "/etc/ros2_medkit/manifest_fragments"
3541
runtime:
3642
# Manifest defines components, no need for synthetic ones.
3743
create_synthetic_components: false
@@ -48,3 +54,6 @@ ros2_medkit_gateway:
4854
plugins.ota_update_plugin.catalog_url: "http://ota_update_server:9000"
4955
plugins.ota_update_plugin.staging_dir: "/tmp/ota_staging"
5056
plugins.ota_update_plugin.install_dir: "/ws/install"
57+
# Same path the gateway has under discovery.manifest.fragments_dir.
58+
# Plugin drops one yaml per Install and removes it on Uninstall.
59+
plugins.ota_update_plugin.fragments_dir: "/etc/ros2_medkit/manifest_fragments"

demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,22 @@ class OtaUpdatePlugin : public ros2_medkit_gateway::GatewayPlugin, public ros2_m
7373
void poll_and_register_catalog();
7474

7575
private:
76+
// Manifest-fragment helpers. Plugins that deploy new nodes at runtime
77+
// are expected to drop a fragment yaml in `fragments_dir_` and then
78+
// notify the gateway so its ManifestManager re-merges. Without this
79+
// the new app shows up as an "Orphan node (not in manifest)" warn
80+
// log and never attaches to the manifest entity tree.
81+
tl::expected<void, std::string> write_install_fragment(const std::string & update_id,
82+
const nlohmann::json & metadata);
83+
tl::expected<void, std::string> remove_install_fragment(const std::string & update_id);
84+
void notify_manifest_changed();
85+
7686
std::string catalog_url_;
7787
std::string staging_dir_;
7888
std::string install_dir_;
89+
std::string fragments_dir_;
90+
91+
ros2_medkit_gateway::PluginContext * context_{nullptr};
7992

8093
std::mutex mu_;
8194
std::map<std::string, nlohmann::json> registry_;

demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@
1616

1717
#include <cstdio>
1818
#include <cstdlib>
19+
#include <cstring>
1920
#include <filesystem>
21+
#include <fstream>
2022
#include <utility>
2123

24+
#include <ros2_medkit_gateway/plugins/entity_change_scope.hpp>
25+
#include <ros2_medkit_gateway/plugins/plugin_context.hpp>
26+
2227
#include "catalog_client.hpp"
2328
#include "operation_dispatcher.hpp"
2429
#include "process_runner.hpp"
@@ -76,12 +81,22 @@ void OtaUpdatePlugin::configure(const nlohmann::json & config) {
7681
catalog_url_ = config.value("catalog_url", "http://ota_update_server:9000");
7782
staging_dir_ = config.value("staging_dir", "/tmp/ota_staging");
7883
install_dir_ = config.value("install_dir", "/ws/install");
84+
// Where this plugin drops manifest fragments for OTA-installed apps.
85+
// Must equal the path the gateway has configured under
86+
// discovery.manifest.fragments_dir, otherwise the gateway won't pick
87+
// them up on reload. Empty disables fragment writes (legacy behavior:
88+
// installed nodes appear as orphans in the entity tree).
89+
fragments_dir_ = config.value("fragments_dir", "");
7990
if (!catalog_client_) {
8091
catalog_client_ = std::make_unique<CatalogClient>(catalog_url_);
8192
}
8293
}
8394

84-
void OtaUpdatePlugin::set_context(ros2_medkit_gateway::PluginContext & /*context*/) {
95+
void OtaUpdatePlugin::set_context(ros2_medkit_gateway::PluginContext & context) {
96+
// Hold on to the context so post-execute we can ask the gateway to
97+
// re-merge manifest fragments and rerun discovery via
98+
// notify_entities_changed.
99+
context_ = &context;
85100
poll_and_register_catalog();
86101
}
87102

@@ -245,6 +260,12 @@ tl::expected<void, UpdateBackendErrorInfo> OtaUpdatePlugin::execute(
245260
if (!sp) {
246261
return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "spawn failed: " + sp.error()});
247262
}
263+
// Update flow: same app id (the binary swapped in is bound to the
264+
// same scan_sensor_node entity as the binary it replaced) - no
265+
// manifest fragment to write, but the gateway still needs to
266+
// rerun discovery so the new pid / process metadata replaces the
267+
// stale entries in the entity cache.
268+
notify_manifest_changed();
248269
reporter.set_progress(100);
249270
return {};
250271
}
@@ -267,6 +288,17 @@ tl::expected<void, UpdateBackendErrorInfo> OtaUpdatePlugin::execute(
267288
if (!sp) {
268289
return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "spawn failed: " + sp.error()});
269290
}
291+
// Install flow: NEW app entity. Write a manifest fragment so the
292+
// gateway picks the new app up under the manifest tree (otherwise
293+
// it stays as an "Orphan node (not in manifest)" warn log and
294+
// never appears under the turtlebot3 component / Functions
295+
// listing). Notify even when fragment write fails - the spawn
296+
// already happened and discovery should still see the new node.
297+
if (auto fr = write_install_fragment(id, metadata); !fr) {
298+
std::fprintf(stderr, "[ota_update_plugin] fragment write failed for %s: %s\n", id.c_str(),
299+
fr.error().c_str());
300+
}
301+
notify_manifest_changed();
270302
reporter.set_progress(100);
271303
return {};
272304
}
@@ -282,6 +314,16 @@ tl::expected<void, UpdateBackendErrorInfo> OtaUpdatePlugin::execute(
282314
std::error_code ec;
283315
fs::remove_all(install_dir_ + "/" + target_package, ec);
284316
}
317+
// Uninstall: drop any fragment we wrote at install time and rerun
318+
// discovery so the entity tree no longer lists the now-dead app.
319+
// Entities defined in the base manifest stay - fragments only
320+
// ADD, they can't remove base-manifest declarations - those
321+
// entries just go offline.
322+
if (auto fr = remove_install_fragment(id); !fr) {
323+
std::fprintf(stderr, "[ota_update_plugin] fragment remove failed for %s: %s\n", id.c_str(),
324+
fr.error().c_str());
325+
}
326+
notify_manifest_changed();
285327
reporter.set_progress(100);
286328
return {};
287329
}
@@ -293,4 +335,87 @@ tl::expected<bool, UpdateBackendErrorInfo> OtaUpdatePlugin::supports_automated(c
293335
return false;
294336
}
295337

338+
namespace {
339+
340+
// Build the YAML body for a single OTA-installed app. We hand-emit the
341+
// minimal subset the gateway's manifest parser accepts (no quoting
342+
// edge cases in our generated values, so a yaml-cpp roundtrip would be
343+
// overkill). The base manifest defines the `turtlebot3` component;
344+
// fragments only ever add apps onto it.
345+
std::string render_install_fragment(const std::string & app_id, const std::string & node_name,
346+
const std::string & description) {
347+
std::string out;
348+
out += "manifest_version: \"1.0\"\n";
349+
out += "apps:\n";
350+
out += " - id: " + app_id + "\n";
351+
out += " name: \"" + app_id + "\"\n";
352+
out += " category: \"ota-installed\"\n";
353+
out += " is_located_on: turtlebot3\n";
354+
out += " description: \"" + description + "\"\n";
355+
out += " ros_binding: { node_name: " + node_name + ", namespace: / }\n";
356+
return out;
357+
}
358+
359+
} // namespace
360+
361+
tl::expected<void, std::string> OtaUpdatePlugin::write_install_fragment(const std::string & update_id,
362+
const nlohmann::json & metadata) {
363+
if (fragments_dir_.empty()) return {};
364+
365+
const std::string node_name = metadata.value("x_medkit_executable", "");
366+
// SOVD ISO 17978-3 reports the target entity via `added_components`
367+
// (it's an array; for an OTA install we always have exactly one).
368+
std::string app_id;
369+
if (metadata.contains("added_components") && metadata["added_components"].is_array()
370+
&& !metadata["added_components"].empty()) {
371+
app_id = metadata["added_components"][0].get<std::string>();
372+
}
373+
if (node_name.empty() || app_id.empty()) {
374+
return tl::make_unexpected(
375+
"metadata missing x_medkit_executable / added_components for fragment");
376+
}
377+
const std::string description = "OTA-installed via " + update_id;
378+
379+
std::error_code ec;
380+
fs::create_directories(fragments_dir_, ec);
381+
if (ec) {
382+
return tl::make_unexpected("create fragments_dir failed: " + ec.message());
383+
}
384+
385+
const std::string final_path = fragments_dir_ + "/" + update_id + ".yaml";
386+
const std::string tmp_path = fragments_dir_ + "/.tmp-" + update_id + ".yaml";
387+
// Atomic publish per ManifestManager's fragment contract: write to
388+
// tmp, fsync, rename. The gateway's fragment scanner runs on the
389+
// notify_entities_changed thread - a half-written file would fail
390+
// the manifest reload and roll back the entire merge.
391+
{
392+
std::ofstream f(tmp_path, std::ios::binary | std::ios::trunc);
393+
if (!f) return tl::make_unexpected("open tmp fragment failed: " + tmp_path);
394+
f << render_install_fragment(app_id, node_name, description);
395+
f.flush();
396+
if (!f) return tl::make_unexpected("write tmp fragment failed: " + tmp_path);
397+
}
398+
if (std::rename(tmp_path.c_str(), final_path.c_str()) != 0) {
399+
return tl::make_unexpected("rename fragment failed: " + std::string(std::strerror(errno)));
400+
}
401+
return {};
402+
}
403+
404+
tl::expected<void, std::string> OtaUpdatePlugin::remove_install_fragment(const std::string & update_id) {
405+
if (fragments_dir_.empty()) return {};
406+
std::error_code ec;
407+
fs::remove(fragments_dir_ + "/" + update_id + ".yaml", ec);
408+
// Missing-file is fine (uninstall of an entity that lived in the
409+
// base manifest, never had a fragment); other errors are reported.
410+
if (ec && ec != std::errc::no_such_file_or_directory) {
411+
return tl::make_unexpected("remove fragment failed: " + ec.message());
412+
}
413+
return {};
414+
}
415+
416+
void OtaUpdatePlugin::notify_manifest_changed() {
417+
if (!context_) return;
418+
context_->notify_entities_changed(ros2_medkit_gateway::EntityChangeScope::full_refresh());
419+
}
420+
296421
} // namespace ota_update_plugin

0 commit comments

Comments
 (0)