diff --git a/score/launch_manager/docs/user_guide/configuration.rst b/score/launch_manager/docs/user_guide/configuration.rst index a51caa11da..1aadffcf6b 100644 --- a/score/launch_manager/docs/user_guide/configuration.rst +++ b/score/launch_manager/docs/user_guide/configuration.rst @@ -208,6 +208,21 @@ component_properties (object) * **Allowed Values:** * ``"Running"``: The process has started and reached its running state. * ``"Terminated"``: The process has started, reached its running state, and then terminated successfully. + * **file_state** (object, optional) + * **Description:** Specifies a ready condition based on the existence of a file at a given path. + * **Properties:** + * **file_path** (string, required) + * **Description:** Specifies the absolute path to the file being watched. + * **state** (string, optional) + * **Description:** Specifies the required existence state of the file. + * **Allowed Values:** + * ``"Exists"``: The component is ready when the file at ``file_path`` exists. + * ``"NotExisting"``: The component is ready when the file at ``file_path`` does not exist. + * **Default:** ``"Exists"`` + * **polling_interval** (number, optional) + * **Description:** Specifies the time interval, in seconds (e.g., ``0.3`` for 300 milliseconds), at which the **Launch Manager** checks the file existence state. + * **Constraint:** Must be greater than 0. + * **Default:** ``0.01`` .. _lm_conf_deployment_config_object_: diff --git a/score/launch_manager/src/daemon/src/configuration/component_config.hpp b/score/launch_manager/src/daemon/src/configuration/component_config.hpp index f3cc4e538b..ab17ef8726 100644 --- a/score/launch_manager/src/daemon/src/configuration/component_config.hpp +++ b/score/launch_manager/src/daemon/src/configuration/component_config.hpp @@ -14,9 +14,11 @@ #define COMPONENT_CONFIG_HPP #include +#include #include #include #include +#include #include #include "score/mw/launch_manager/configuration/environment_config.hpp" @@ -48,24 +50,34 @@ struct ApplicationProfile std::optional alive_supervision; }; -enum class ProcessState : uint8_t +enum class FileExistenceState : uint8_t { - Running = 0, - Terminated = 1 + Exists = 0, + NotExisting, +}; + +struct FileState +{ + std::string file_path; + FileExistenceState state; + std::chrono::milliseconds polling_interval; }; -struct ReadyCondition +enum class ProcessState : std::uint8_t { - ProcessState process_state{ProcessState::Running}; + Running = 0, + Terminated = 1 }; +using ReadyCondition = std::variant; + struct ComponentProperties { std::string binary_name; ApplicationProfile application_profile; std::vector depends_on; std::vector process_arguments; - std::optional ready_condition; + ReadyCondition ready_condition; }; struct Sandbox { diff --git a/score/launch_manager/src/daemon/src/configuration/config.hpp b/score/launch_manager/src/daemon/src/configuration/config.hpp index 0925536253..3444b9b784 100644 --- a/score/launch_manager/src/daemon/src/configuration/config.hpp +++ b/score/launch_manager/src/daemon/src/configuration/config.hpp @@ -14,10 +14,12 @@ #define CONFIG_HPP #include +#include #include #include #include #include +#include #include #include "score/mw/launch_manager/configuration/component_config.hpp" diff --git a/score/launch_manager/src/daemon/src/configuration/config_schema/launch_manager.schema.json b/score/launch_manager/src/daemon/src/configuration/config_schema/launch_manager.schema.json index 371630d73b..98b598d8fe 100644 --- a/score/launch_manager/src/daemon/src/configuration/config_schema/launch_manager.schema.json +++ b/score/launch_manager/src/daemon/src/configuration/config_schema/launch_manager.schema.json @@ -89,9 +89,48 @@ "Terminated" ], "description": "Specifies the required state of the component's POSIX process. 'Running': the process has started and reached its running state. 'Terminated': the process has started, reached its running state, and then terminated successfully." + }, + "file_state": { + "type": "object", + "description": "Specifies a ready condition based on the existence of a file at a given path.", + "properties": { + "file_path": { + "type": "string", + "pattern": "^/(?:[^/]+(?:/[^/]+)*)$", + "description": "Specifies the absolute path to the file being watched." + }, + "state": { + "type": "string", + "enum": [ + "Exists", + "NotExisting" + ], + "description": "Specifies the required existence of the file. 'Exists': the file must be present at 'file_path'. 'NotExisting': the file must be absent from 'file_path'. Defaults to 'Exists' if not specified." + }, + "polling_interval": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Specifies the time interval, in seconds (e.g., '0.3' for 300 milliseconds), at which the Launch Manager checks the file existence. Defaults to 10 milliseconds." + } + }, + "required": [ + "file_path" + ], + "additionalProperties": false } }, - "required": [], + "oneOf": [ + { + "required": [ + "process_state" + ] + }, + { + "required": [ + "file_state" + ] + } + ], "additionalProperties": false } }, @@ -488,4 +527,4 @@ "initial_run_target" ], "additionalProperties": false -} \ No newline at end of file +} diff --git a/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_config_loader_UT.cpp b/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_config_loader_UT.cpp index 8dd30bb0e6..db0269bfd4 100644 --- a/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_config_loader_UT.cpp +++ b/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_config_loader_UT.cpp @@ -31,10 +31,12 @@ namespace namespace fb = score::mw::lifecycle::internal::configuration::fb; using ::testing::Eq; +using ::testing::FieldsAre; using ::testing::IsFalse; using ::testing::IsNull; using ::testing::IsTrue; using ::testing::StrEq; +using ::testing::VariantWith; const score::filesystem::Path kTestPath{"/tmp/test_config.bin"}; @@ -260,14 +262,58 @@ TEST_F(FlatbufferConfigLoaderTest, LoadSingleComponent) EXPECT_THAT(comp.component_properties.depends_on[0], Eq("other_comp")); ASSERT_THAT(comp.component_properties.process_arguments.size(), Eq(1U)); EXPECT_THAT(comp.component_properties.process_arguments[0], Eq("--verbose")); - ASSERT_THAT(comp.component_properties.ready_condition.has_value(), IsTrue()); - EXPECT_THAT(comp.component_properties.ready_condition->process_state, Eq(ProcessState::Running)); + EXPECT_THAT(comp.component_properties.ready_condition, VariantWith(Eq(ProcessState::Running))); EXPECT_THAT(comp.deployment_config.ready_timeout_ms, Eq(1500U)); EXPECT_THAT(comp.deployment_config.shutdown_timeout_ms, Eq(2500U)); EXPECT_THAT(comp.deployment_config.bin_dir, Eq("/opt/bin")); EXPECT_THAT(comp.deployment_config.working_dir, Eq("/tmp")); } +TEST_F(FlatbufferConfigLoaderTest, LoadSingleComponentWithFileState) +{ + RecordProperty("Description", "Loads a component whose ready_condition includes a file_state."); + + ::flatbuffers::FlatBufferBuilder fbb; + + auto app_profile = fb::CreateApplicationProfile(fbb, fb::ApplicationType::Native, false /*is_self_terminating*/); + auto bin_name = fbb.CreateString("my_binary"); + auto file_state = + fb::CreateFileStateDirect(fbb, "/tmp/ready", fb::FileExistenceState::Exists, 0.01 /*polling_interval*/); + auto ready_cond = fb::CreateReadyCondition(fbb, std::nullopt, file_state); + auto comp_props = fb::CreateComponentProperties( + fbb, bin_name, app_profile, 0 /*depends_on*/, 0 /*process_arguments*/, ready_cond); + + auto bin_dir = fbb.CreateString("/opt/bin"); + auto work_dir = fbb.CreateString("/tmp"); + auto sandbox = buildDefaultSandbox(fbb); + auto deploy = fb::CreateDeploymentConfig( + fbb, + 1.5 /*ready_timeout*/, + 2.5 /*shutdown_timeout*/, + 0 /*environmental_variables*/, + bin_dir, + work_dir, + 0 /*ready_recovery_action*/, + 0 /*recovery_action*/, + sandbox); + + auto comp_name = fbb.CreateString("TestComponent"); + auto comp_desc = fbb.CreateString("A test component"); + auto component = fb::CreateComponent(fbb, comp_name, comp_desc, comp_props, deploy); + auto comps = fbb.CreateVector(std::vector<::flatbuffers::Offset>{component}); + + auto result = loadBuffer(buildConfigWithComponents(fbb, comps)); + + ASSERT_THAT(result.has_value(), IsTrue()); + ASSERT_THAT(result->components().size(), Eq(1U)); + + const auto& comp = result->components()[0]; + EXPECT_THAT( + comp.component_properties.ready_condition, + VariantWith( + FieldsAre(Eq("/tmp/ready"), Eq(FileExistenceState::Exists), Eq(std::chrono::milliseconds{10})))); +} + TEST_F(FlatbufferConfigLoaderTest, LoadRunTargets) { RecordProperty("Description", "Loads run targets with dependencies and transition timeout."); @@ -620,7 +666,8 @@ TEST_F(FlatbufferConfigLoaderTest, OptionalWatchdogAbsent) TEST_F(FlatbufferConfigLoaderTest, OptionalReadyConditionAbsent) { - RecordProperty("Description", "When no ready_condition is present on a component, it is nullopt."); + RecordProperty( + "Description", "When no ready_condition is present on a component, it defaults to ProcessState::Running."); ::flatbuffers::FlatBufferBuilder fbb; @@ -634,7 +681,9 @@ TEST_F(FlatbufferConfigLoaderTest, OptionalReadyConditionAbsent) auto result = loadBuffer(buildConfigWithComponents(fbb, comps)); ASSERT_THAT(result.has_value(), IsTrue()); - EXPECT_THAT(result->components()[0].component_properties.ready_condition.has_value(), IsFalse()); + EXPECT_THAT( + result->components()[0].component_properties.ready_condition, + VariantWith(Eq(ProcessState::Running))); } // ============================================================================ diff --git a/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters.cpp b/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters.cpp index 3b5df14b54..1f5724bbd3 100644 --- a/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters.cpp +++ b/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters.cpp @@ -103,6 +103,18 @@ ProcessState convertProcessState(fb::ProcessState fb_state) } } +FileExistenceState convertFileExistenceState(fb::FileExistenceState fb_state) +{ + switch (fb_state) + { + case fb::FileExistenceState::NotExisting: + return FileExistenceState::NotExisting; + case fb::FileExistenceState::Exists: + return FileExistenceState::Exists; + } + SCORE_LANGUAGE_FUTURECPP_UNREACHABLE(); +} + score::cpp::expected convertSchedulingPolicy(fb::SchedulingPolicy policy) { switch (policy) @@ -296,19 +308,59 @@ score::cpp::expected convertApplicatio return result; } +score::cpp::expected convertFileState(const fb::FileState& fb_fs) +{ + SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE( + fb_fs.file_path(), "FileState::file_path must never be nullptr as it is required in the schema"); + SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE( + fb_fs.polling_interval() != 0.0, + "No FileState::polling_interval is configured, this should have been defaulted with the script."); + + auto polling_interval_ms = secondsToMs(fb_fs.polling_interval()); + if (!polling_interval_ms.has_value()) + { + LM_LOG_ERROR() << "Invalid value for FileState::polling_interval"; + return score::cpp::make_unexpected(polling_interval_ms.error()); + } + return FileState{ + fb_fs.file_path()->str(), + convertFileExistenceState(fb_fs.state()), + std::chrono::milliseconds{*polling_interval_ms}}; +} + score::cpp::expected convertReadyCondition(const fb::ReadyCondition* fb_rc) { - ReadyCondition result{}; - if (fb_rc != nullptr) + SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE( + fb_rc != nullptr, "No ReadyCondition is configured, this should have been defaulted with the script."); + + const bool has_process_state = fb_rc->process_state().has_value(); + const bool has_file_state = fb_rc->file_state() != nullptr; + + if (has_process_state && has_file_state) + { + LM_LOG_ERROR() << "ReadyCondition cannot have both process_state and file_state set"; + return score::cpp::make_unexpected(IConfigLoader::Error::InvalidFormat); + } + + if (has_process_state) { - auto process_state = requireScalarValue(fb_rc->process_state(), "ReadyCondition::process_state"); - if (!process_state.has_value()) + return ReadyCondition{convertProcessState(*fb_rc->process_state())}; + } + + if (has_file_state) + { + auto file_state = convertFileState(*(fb_rc->file_state())); + if (!file_state.has_value()) { - return score::cpp::make_unexpected(process_state.error()); + LM_LOG_ERROR() << "Invalid value for ReadyCondition::file_state"; + return score::cpp::make_unexpected(file_state.error()); } - result.process_state = convertProcessState(*process_state); - } - return result; + + // convertFileState only returns nullopt for a nullptr input, which is ruled out above + return ReadyCondition{*file_state}; + }; + + SCORE_LANGUAGE_FUTURECPP_UNREACHABLE(); } score::cpp::expected convertComponentProperties( @@ -334,12 +386,12 @@ score::cpp::expected convertComponent result.process_arguments = convertStringVector(fb_cp->process_arguments()); if (fb_cp->ready_condition() != nullptr) { - auto ready_cond = convertReadyCondition(fb_cp->ready_condition()); - if (!ready_cond.has_value()) + auto ready_condition = convertReadyCondition(fb_cp->ready_condition()); + if (!ready_condition.has_value()) { - return score::cpp::make_unexpected(ready_cond.error()); + return score::cpp::make_unexpected(ready_condition.error()); } - result.ready_condition = std::move(*ready_cond); + result.ready_condition = std::move(ready_condition.value()); } } return result; diff --git a/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters.hpp b/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters.hpp index 4952b6ae64..259344d163 100644 --- a/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters.hpp +++ b/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters.hpp @@ -66,6 +66,10 @@ score::cpp::expected validateRange(int64_t value, [[nodiscard]] ApplicationType convertApplicationType(fb::ApplicationType fb_type); /// @brief Converts a FlatBuffer ProcessState enum to the config ProcessState. [[nodiscard]] ProcessState convertProcessState(fb::ProcessState fb_state); +/// @brief Converts a FlatBuffer FileState table to the config equivalent, or nullopt if absent. +[[nodiscard]] score::cpp::expected convertFileState(const fb::FileState& fb_fs); +/// @brief Converts a FlatBuffer FileExistenceState enum to the config equivalent. +[[nodiscard]] FileExistenceState convertFileExistenceState(fb::FileExistenceState fb_state); /// @brief Converts a FlatBuffer SchedulingPolicy enum to a POSIX scheduling policy constant. [[nodiscard]] score::cpp::expected convertSchedulingPolicy(fb::SchedulingPolicy policy); @@ -101,7 +105,7 @@ score::cpp::expected validateRange(int64_t value, /// @brief Converts a FlatBuffer ApplicationProfile to the config equivalent. [[nodiscard]] score::cpp::expected convertApplicationProfile( const fb::ApplicationProfile* fb_ap); -/// @brief Converts a FlatBuffer ReadyCondition to the config equivalent. +/// @brief Converts a FlatBuffer ReadyCondition to the config equivalent, or nullopt if not configured. [[nodiscard]] score::cpp::expected convertReadyCondition( const fb::ReadyCondition* fb_rc); /// @brief Converts a FlatBuffer ComponentProperties to the config equivalent. diff --git a/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters_UT.cpp b/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters_UT.cpp index ffcb589ed5..3591f82cbe 100644 --- a/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters_UT.cpp +++ b/score/launch_manager/src/daemon/src/configuration/details/flatbuffer_type_converters_UT.cpp @@ -560,7 +560,13 @@ TEST_F(ConverterTest, ConvertApplicationProfileMissingSelfTerminatingReturnsErro EXPECT_THAT(result.error(), Eq(IConfigLoader::Error::InvalidFormat)); } -TEST_F(ConverterTest, ConvertReadyConditionValid) +TEST_F(ConverterTest, ConvertReadyConditionNullDeath) +{ + RecordProperty("Description", "convertReadyCondition fires an assertion when passed nullptr."); + EXPECT_DEATH(static_cast(convertReadyCondition(nullptr)), ".*"); +} + +TEST_F(ConverterTest, ConvertReadyConditionWithProcessState) { RecordProperty("Description", "convertReadyCondition maps process_state correctly."); ::flatbuffers::FlatBufferBuilder fbb; @@ -570,22 +576,153 @@ TEST_F(ConverterTest, ConvertReadyConditionValid) auto result = convertReadyCondition(ptr); ASSERT_THAT(result.has_value(), IsTrue()); - EXPECT_THAT(result->process_state, Eq(ProcessState::Terminated)); + EXPECT_THAT(*result, ::testing::VariantWith(ProcessState::Terminated)); } -TEST_F(ConverterTest, ConvertReadyConditionMissingProcessStateReturnsError) +TEST_F(ConverterTest, ConvertReadyConditionWithFileState) { - RecordProperty("Description", "Missing process_state returns InvalidFormat."); + RecordProperty("Description", "convertReadyCondition maps file_state correctly."); + ::flatbuffers::FlatBufferBuilder fbb; + auto fs = fb::CreateFileStateDirect(fbb, "/tmp/ready", fb::FileExistenceState::Exists, 0.01 /*polling_interval*/); + auto rc = fb::CreateReadyCondition(fbb, ::flatbuffers::nullopt /*process_state*/, fs); + fbb.Finish(rc); + const auto* ptr = ::flatbuffers::GetRoot(fbb.GetBufferPointer()); + + auto result = convertReadyCondition(ptr); + ASSERT_THAT(result.has_value(), IsTrue()); + EXPECT_THAT(*result, ::testing::VariantWith(::testing::Field(&FileState::file_path, Eq("/tmp/ready")))); +} + +TEST_F(ConverterTest, ConvertReadyConditionWithBothStatesReturnsError) +{ + RecordProperty( + "Description", "convertReadyCondition with both process_state and file_state returns InvalidFormat."); + ::flatbuffers::FlatBufferBuilder fbb; + auto fs = fb::CreateFileStateDirect(fbb, "/tmp/ready", fb::FileExistenceState::Exists); + auto rc = fb::CreateReadyCondition(fbb, fb::ProcessState::Running, fs); + fbb.Finish(rc); + const auto* ptr = ::flatbuffers::GetRoot(fbb.GetBufferPointer()); + + auto result = convertReadyCondition(ptr); + ASSERT_THAT(result.has_value(), IsFalse()); + EXPECT_THAT(result.error(), Eq(IConfigLoader::Error::InvalidFormat)); +} + +TEST_F(ConverterTest, ConvertReadyConditionWithNeitherStateDeath) +{ + RecordProperty( + "Description", + "convertReadyCondition fires an assertion if neither process_state nor file_state is configured, as the " + "configuration script always defaults one of them."); ::flatbuffers::FlatBufferBuilder fbb; auto rc = fb::CreateReadyCondition(fbb); fbb.Finish(rc); const auto* ptr = ::flatbuffers::GetRoot(fbb.GetBufferPointer()); + EXPECT_DEATH(static_cast(convertReadyCondition(ptr)), ".*"); +} + +TEST_F(ConverterTest, ConvertReadyConditionWithInvalidPollingIntervalReturnsError) +{ + RecordProperty("Description", "convertReadyCondition propagates an invalid FileState::polling_interval."); + ::flatbuffers::FlatBufferBuilder fbb; + auto fs = fb::CreateFileStateDirect(fbb, "/tmp/ready", fb::FileExistenceState::Exists, -1.0 /*polling_interval*/); + auto rc = fb::CreateReadyCondition(fbb, ::flatbuffers::nullopt /*process_state*/, fs); + fbb.Finish(rc); + const auto* ptr = ::flatbuffers::GetRoot(fbb.GetBufferPointer()); + auto result = convertReadyCondition(ptr); ASSERT_THAT(result.has_value(), IsFalse()); EXPECT_THAT(result.error(), Eq(IConfigLoader::Error::InvalidFormat)); } +TEST_F(ConverterTest, ConvertFileExistenceStateMapsDeath) +{ + RecordProperty("Description", "convertFileExistenceState Fires an assertion if an undefined enum is given."); + EXPECT_DEATH( + static_cast(convertFileExistenceState( + static_cast(static_cast(fb::FileExistenceState::MAX) + 1))), + ".*"); +} + +TEST_F(ConverterTest, ConvertFileExistenceStateMapsBothValues) +{ + RecordProperty("Description", "convertFileExistenceState maps both enum values correctly."); + EXPECT_THAT(convertFileExistenceState(fb::FileExistenceState::Exists), Eq(FileExistenceState::Exists)); + EXPECT_THAT(convertFileExistenceState(fb::FileExistenceState::NotExisting), Eq(FileExistenceState::NotExisting)); +} + +TEST_F(ConverterTest, ConvertFileStateValid) +{ + RecordProperty("Description", "convertFileState maps file_path, an explicit state and polling_interval correctly."); + ::flatbuffers::FlatBufferBuilder fbb; + auto fs = + fb::CreateFileStateDirect(fbb, "/tmp/ready", fb::FileExistenceState::NotExisting, 0.3 /*polling_interval*/); + fbb.Finish(fs); + const auto* ptr = ::flatbuffers::GetRoot(fbb.GetBufferPointer()); + + auto result = convertFileState(*ptr); + ASSERT_THAT(result.has_value(), IsTrue()); + EXPECT_THAT(result->file_path, Eq("/tmp/ready")); + EXPECT_THAT(result->state, Eq(FileExistenceState::NotExisting)); + EXPECT_THAT(result->polling_interval, Eq(std::chrono::milliseconds{300})); +} + +TEST_F(ConverterTest, ConvertFileStateDefaultsToExists) +{ + RecordProperty("Description", "convertFileState defaults state to Exists if it is not set."); + ::flatbuffers::FlatBufferBuilder fbb; + // state is omitted from the buffer since it matches the schema default + auto fs = fb::CreateFileStateDirect(fbb, "/tmp/ready", fb::FileExistenceState::Exists, 0.01 /*polling_interval*/); + fbb.Finish(fs); + const auto* ptr = ::flatbuffers::GetRoot(fbb.GetBufferPointer()); + + auto result = convertFileState(*ptr); + ASSERT_THAT(result.has_value(), IsTrue()); + EXPECT_THAT(result->state, Eq(FileExistenceState::Exists)); + EXPECT_THAT(result->polling_interval, Eq(std::chrono::milliseconds{10})); +} + +TEST_F(ConverterTest, ConvertFileStateWithoutPollingIntervalDeath) +{ + RecordProperty( + "Description", + "convertFileState fires an assertion if polling_interval is not configured, as the configuration script " + "always defaults it."); + ::flatbuffers::FlatBufferBuilder fbb; + auto fs = fb::CreateFileStateDirect(fbb, "/tmp/ready"); + fbb.Finish(fs); + const auto* ptr = ::flatbuffers::GetRoot(fbb.GetBufferPointer()); + + EXPECT_DEATH(static_cast(convertFileState(*ptr)), ".*"); +} + +TEST_F(ConverterTest, ConvertFileStateNegativePollingIntervalReturnsError) +{ + RecordProperty("Description", "convertFileState returns InvalidFormat for a negative polling_interval."); + ::flatbuffers::FlatBufferBuilder fbb; + auto fs = fb::CreateFileStateDirect(fbb, "/tmp/ready", fb::FileExistenceState::Exists, -0.5 /*polling_interval*/); + fbb.Finish(fs); + const auto* ptr = ::flatbuffers::GetRoot(fbb.GetBufferPointer()); + + auto result = convertFileState(*ptr); + ASSERT_THAT(result.has_value(), IsFalse()); + EXPECT_THAT(result.error(), Eq(IConfigLoader::Error::InvalidFormat)); +} + +TEST_F(ConverterTest, ConvertFileStateSubMillisecondPollingIntervalReturnsError) +{ + RecordProperty("Description", "convertFileState returns InvalidFormat for a sub-millisecond polling_interval."); + ::flatbuffers::FlatBufferBuilder fbb; + auto fs = fb::CreateFileStateDirect(fbb, "/tmp/ready", fb::FileExistenceState::Exists, 0.0001 /*polling_interval*/); + fbb.Finish(fs); + const auto* ptr = ::flatbuffers::GetRoot(fbb.GetBufferPointer()); + + auto result = convertFileState(*ptr); + ASSERT_THAT(result.has_value(), IsFalse()); + EXPECT_THAT(result.error(), Eq(IConfigLoader::Error::InvalidFormat)); +} + TEST_F(ConverterTest, ConvertSandboxValid) { RecordProperty("Description", "convertSandbox maps all fields including optional ones."); diff --git a/score/launch_manager/src/daemon/src/configuration/details/lm_flatcfg.fbs b/score/launch_manager/src/daemon/src/configuration/details/lm_flatcfg.fbs index 83529b05ce..23d352c1a1 100644 --- a/score/launch_manager/src/daemon/src/configuration/details/lm_flatcfg.fbs +++ b/score/launch_manager/src/daemon/src/configuration/details/lm_flatcfg.fbs @@ -26,6 +26,12 @@ enum ProcessState : byte { Terminated = 1 } +// Specifies the required existence state of a watched file. +enum FileExistenceState : byte { + Exists = 0, + NotExisting = 1 +} + // Scheduling policy for a component's initial thread. enum SchedulingPolicy : byte { OTHER = 0, @@ -53,9 +59,23 @@ table ApplicationProfile { alive_supervision:ComponentAliveSupervision; // optional } +// Defines a ready condition based on the existence state of a file at a given path. +table FileState { + // Absolute path to the file being watched. + file_path:string (required); // required + // Existence state of the file. Defaults to Exists if not specified. + state:FileExistenceState = Exists; // optional, defaults to Exists + // Time in seconds to wait between each poll if the file is present. + polling_interval: double; // required +} + // Defines the conditions that determine when the component enters the ready state. +// Either process_state or file_state should be set, but not both. table ReadyCondition { - process_state:ProcessState = null; // required + // Required state of the component's POSIX process. + process_state:ProcessState = null; // optional + // File existence state condition. + file_state:FileState; // optional } // Defines essential characteristics of a software component. diff --git a/score/launch_manager/src/daemon/src/process_group_manager/details/process_info_node.cpp b/score/launch_manager/src/daemon/src/process_group_manager/details/process_info_node.cpp index fd43b78851..418af14417 100644 --- a/score/launch_manager/src/daemon/src/process_group_manager/details/process_info_node.cpp +++ b/score/launch_manager/src/daemon/src/process_group_manager/details/process_info_node.cpp @@ -53,20 +53,26 @@ IComponent::RequestResult ProcessInfoNode::tryReportCompletion(score::mw::lifecy { ProcessState desired_state{}; - SCORE_LANGUAGE_FUTURECPP_ASSERT_MESSAGE( - config_.component_properties.ready_condition.has_value() == true, - "component has no ready condition, config should have created a default one"); - auto ready_condition = config_.component_properties.ready_condition.value(); + const auto& ready_condition = config_.component_properties.ready_condition; + + std::visit( + [&desired_state](auto&& arg) { + using ReadyCondT = std::decay_t; + if constexpr (std::is_same_v) + { + switch (arg) + { + case configuration::ProcessState::Running: + desired_state = ProcessState::kRunning; + break; + case configuration::ProcessState::Terminated: + desired_state = ProcessState::kTerminated; + break; + } + } + }, + ready_condition); - switch (ready_condition.process_state) - { - case configuration::ProcessState::Running: - desired_state = ProcessState::kRunning; - break; - case configuration::ProcessState::Terminated: - desired_state = ProcessState::kTerminated; - break; - } if (new_state == ProcessState::kFailed) { // Didn't reach running or startup diff --git a/scripts/config_mapping/lifecycle_config.py b/scripts/config_mapping/lifecycle_config.py index 9bde317b1d..1ee449102a 100644 --- a/scripts/config_mapping/lifecycle_config.py +++ b/scripts/config_mapping/lifecycle_config.py @@ -90,7 +90,16 @@ def report_error(message): # There are various dictionaries in the config where only a single entry is allowed. # We do not want to merge the defaults with the user specified values for these dictionaries. -not_merging_dicts = ["ready_recovery_action", "recovery_action"] +not_merging_dicts = ["ready_recovery_action", "recovery_action", "ready_condition"] + + +# Defaults for the optional fields of a "file_state" ready condition. +# note: these are only default when a file_state is configured so it's no in +# the main default config. +file_state_defaults: Dict[str, Any] = { + "state": "Exists", + "polling_interval": 0.01, +} def load_json_file(file_path: str) -> Dict[str, Any]: @@ -106,6 +115,21 @@ def get_working_dir(deployment_config): return deployment_config.get("working_dir", deployment_config["bin_dir"]) +def apply_file_state_defaults(ready_condition): + """Fill in the optional fields of a "file_state" ready condition with + their defaults. Only done if a "file_state" ready condition is configured. + """ + file_state = ready_condition.get("file_state") + is_configured = isinstance(file_state, dict) + if not is_configured: + return + + # user config takes precedence over the defaults + merged = dict(file_state_defaults) + merged.update(file_state) + ready_condition["file_state"] = {**merged} + + def preprocess_defaults(global_defaults, config): """ This function takes the input configuration and fills in any missing fields with default values. @@ -169,6 +193,12 @@ def dict_merge_recursive(dict_a, dict_b): component_config.get("deployment_config", {}), ) + apply_file_state_defaults( + new_config["components"][component_name]["component_properties"].get( + "ready_condition", {} + ) + ) + # If the application_type is not supervised, remove alive_supervision # from component_properties even if it was merged from defaults. app_type = new_config["components"][component_name]["component_properties"][ @@ -495,6 +525,21 @@ def get_process_dependencies( def custom_validations(config): success = True + # A ready condition is either process state or file state (right now), but + # never on both. + for component_name, component_config in config["components"].items(): + ready_condition = component_config["component_properties"].get( + "ready_condition", {} + ) + has_process_state = "process_state" in ready_condition + has_file_state = "file_state" in ready_condition + if has_process_state and has_file_state: + report_error( + f"Component '{component_name}': ready_condition must configure either " + '"process_state" or "file_state", but not both.' + ) + success = False + if "fallback_run_target" in config["run_targets"]: report_error( 'RunTarget name "fallback_run_target" is reserved, please choose a different name.' diff --git a/scripts/config_mapping/tests/full_config_test/expected_output/lm_config_gen.json b/scripts/config_mapping/tests/full_config_test/expected_output/lm_config_gen.json index 24629f86bc..16a8645ac0 100644 --- a/scripts/config_mapping/tests/full_config_test/expected_output/lm_config_gen.json +++ b/scripts/config_mapping/tests/full_config_test/expected_output/lm_config_gen.json @@ -86,7 +86,11 @@ "--option" ], "ready_condition": { - "process_state": "Running" + "file_state": { + "state": "Exists", + "polling_interval": 0.01, + "file_path": "/var/run/b/ready" + } } }, "deployment_config": { @@ -143,7 +147,11 @@ ], "process_arguments": [], "ready_condition": { - "process_state": "Running" + "file_state": { + "state": "NotExisting", + "polling_interval": 0.5, + "file_path": "/var/run/c/startup.lock" + } } }, "deployment_config": { diff --git a/scripts/config_mapping/tests/full_config_test/input/lm_config.json b/scripts/config_mapping/tests/full_config_test/input/lm_config.json index 9ec08636a6..33f2c2e701 100644 --- a/scripts/config_mapping/tests/full_config_test/input/lm_config.json +++ b/scripts/config_mapping/tests/full_config_test/input/lm_config.json @@ -127,7 +127,12 @@ "application_type": "Native", "is_self_terminating": true }, - "process_arguments": ["-b", "--option"] + "process_arguments": ["-b", "--option"], + "ready_condition": { + "file_state": { + "file_path": "/var/run/b/ready" + } + } }, "deployment_config": { "bin_dir": "/opt/apps/b", @@ -146,7 +151,14 @@ "application_type": "Reporting", "is_self_terminating": false }, - "depends_on": ["component_a"] + "depends_on": ["component_a"], + "ready_condition": { + "file_state": { + "file_path": "/var/run/c/startup.lock", + "state": "NotExisting", + "polling_interval": 0.5 + } + } }, "deployment_config": { "bin_dir": "/opt/apps/c", diff --git a/scripts/config_mapping/unit_tests.py b/scripts/config_mapping/unit_tests.py index 922438fddf..b8eb8cce71 100644 --- a/scripts/config_mapping/unit_tests.py +++ b/scripts/config_mapping/unit_tests.py @@ -450,6 +450,81 @@ def test_preprocessing_no_defaults_section(): assert result["components"]["c1"]["deployment_config"]["bin_dir"] == "/opt" +def _config_with_file_state(file_state): + return { + "schema_version": 1, + "components": { + "c1": { + "component_properties": { + "binary_name": "c1", + "ready_condition": {"file_state": file_state}, + } + } + }, + "run_targets": {"Startup": {}}, + "initial_run_target": "Startup", + "fallback_run_target": {"transition_timeout": 1}, + } + + +def test_preprocessing_file_state_defaults(): + """ + A file_state ready condition only requires a file_path, state and + polling_interval are filled in with their defaults. + """ + config = _config_with_file_state({"file_path": "/tmp/ready"}) + result = preprocess_defaults(score_defaults, config) + ready_condition = result["components"]["c1"]["component_properties"][ + "ready_condition" + ] + assert ready_condition == { + "file_state": { + "file_path": "/tmp/ready", + "state": "Exists", + "polling_interval": 0.01, + } + } + + +def test_preprocessing_file_state_defaults_overridden(): + """ + User specified file_state values take precedence over the defaults. + """ + config = _config_with_file_state( + {"file_path": "/tmp/ready", "state": "NotExisting", "polling_interval": 0.5} + ) + result = preprocess_defaults(score_defaults, config) + file_state = result["components"]["c1"]["component_properties"]["ready_condition"][ + "file_state" + ] + assert file_state["state"] == "NotExisting" + assert file_state["polling_interval"] == 0.5 + + +def test_preprocessing_file_state_defaults_not_applied_for_process_state(): + """ + Without a file_state ready condition, no file_state defaults are added. + """ + config = { + "schema_version": 1, + "components": { + "c1": { + "component_properties": { + "binary_name": "c1", + "ready_condition": {"process_state": "Terminated"}, + } + } + }, + "run_targets": {"Startup": {}}, + "initial_run_target": "Startup", + "fallback_run_target": {"transition_timeout": 1}, + } + result = preprocess_defaults(score_defaults, config) + assert result["components"]["c1"]["component_properties"]["ready_condition"] == { + "process_state": "Terminated" + } + + # --------------------------------------------------------------------------- # check_cyclic_dependencies # --------------------------------------------------------------------------- @@ -592,7 +667,8 @@ def full_valid_config(): "components": { "app1": { "component_properties": { - "application_profile": {"application_type": "REPORTING"} + "application_profile": {"application_type": "REPORTING"}, + "ready_condition": {"process_state": "Running"}, } } }, @@ -644,6 +720,25 @@ def test_custom_validations_recovery_target_not_fallback(full_valid_config): assert custom_validations(full_valid_config) is False +def test_custom_validations_ready_condition_file_state(full_valid_config): + """A ready condition based on the file state alone is valid.""" + full_valid_config["components"]["app1"]["component_properties"][ + "ready_condition" + ] = {"file_state": {"file_path": "/tmp/ready"}} + assert custom_validations(full_valid_config) is True + + +def test_custom_validations_ready_condition_both_states(full_valid_config): + """process_state and file_state must not be configured at the same time.""" + full_valid_config["components"]["app1"]["component_properties"][ + "ready_condition" + ] = { + "process_state": "Running", + "file_state": {"file_path": "/tmp/ready"}, + } + assert custom_validations(full_valid_config) is False + + def test_custom_validations_missing_fallback_run_target(full_valid_config): """fallback_run_target is mandatory.""" del full_valid_config["fallback_run_target"]