Changes
diff --git a/config.example.yaml b/config.example.yaml
index 9bb112e..c193f8e 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -9,22 +9,23 @@ database_path: status-tracker.sqlite
# Groups are a sequence. Service IDs are mapping keys and must be unique.
groups:
- name: Public
+ # These values apply to services that do not override them.
+ interval:
+ value: 1
+ unit: minute
+ timeout_second: 5
services:
blog:
name: Blog
# Optional description shown below the service name.
description: Personal website
url: https://blog.example/
- interval:
- value: 1
- unit: minute
endpoint:
protocol: HTTP
# If set, only this exact response code is considered good.
# expected_code: 200
# If omitted, the service-level URL is used for HTTP probes.
# url: https://blog.example/
- timeout_second: 5
- name: Infrastructure
services:
diff --git a/prd.md b/prd.md
index 53c9d0b..b432697 100644
--- a/prd.md
+++ b/prd.md
@@ -59,6 +59,9 @@ A simple self-hosted service status tracker.
- In the config file the endpoints are grouped with another level of
grouping. This grouping is also shown in the web UI.
+- Groups may define `interval` and `timeout_second` defaults for their
+ services. Service values override group values; endpoint timeout values
+ remain more specific than group timeout values.
- Stores status history in SQLite db.
- Endpoint status: There should be four possible status: good, bad,
other, and n/a. Right now we can reserve “other” for the case where
diff --git a/src/configuration.cpp b/src/configuration.cpp
index bdf7d55..5e8bda2 100644
--- a/src/configuration.cpp
+++ b/src/configuration.cpp
@@ -6,6 +6,7 @@
#include <fstream>
#include <iterator>
#include <limits>
+#include <optional>
#include <string_view>
#include <unordered_set>
#include <utility>
@@ -132,14 +133,18 @@ std::string lower(std::string value)
return value;
}
-mw::E<std::chrono::steady_clock::duration> parseInterval(
- Node service, std::string_view context)
+mw::E<std::optional<std::chrono::steady_clock::duration>> optionalInterval(
+ Node node, std::string_view context)
{
- auto interval = child(service, "interval");
- if(!interval.readable() || !interval.is_map())
+ auto interval = child(node, "interval");
+ if(!interval.readable())
+ {
+ return std::optional<std::chrono::steady_clock::duration>{};
+ }
+ if(!interval.is_map())
{
return std::unexpected(configError(
- context, "missing interval map"));
+ context, "interval must be a map"));
}
ASSIGN_OR_RETURN(const auto value,
requiredInteger<std::int64_t>(interval, "value", context));
@@ -247,7 +252,11 @@ mw::E<EndpointConfig> parseEndpoint(Node endpoint, std::string_view context)
}
mw::E<ServiceConfig> parseService(Node service, std::string id,
- std::string_view context)
+ std::string_view context,
+ std::optional<
+ std::chrono::steady_clock::duration>
+ group_interval,
+ std::optional<std::int64_t> group_timeout)
{
if(!service.is_map())
{
@@ -283,14 +292,28 @@ mw::E<ServiceConfig> parseService(Node service, std::string id,
return std::unexpected(timeout.error());
}
}
- const auto timeout_value = timeout->value_or(5);
+ const auto timeout_value = timeout->value_or(
+ group_timeout.value_or(5));
if(timeout_value <= 0)
{
return std::unexpected(configError(
context, "timeout_second must be positive"));
}
result.timeout = std::chrono::seconds(timeout_value);
- ASSIGN_OR_RETURN(result.interval, parseInterval(service, context));
+ ASSIGN_OR_RETURN(auto interval, optionalInterval(service, context));
+ if(interval)
+ {
+ result.interval = *interval;
+ }
+ else if(group_interval)
+ {
+ result.interval = *group_interval;
+ }
+ else
+ {
+ return std::unexpected(configError(
+ context, "missing interval map"));
+ }
return result;
}
@@ -377,6 +400,16 @@ mw::E<Configuration> parseTree(ryml::Tree& tree)
const auto group_context = group_location + "." + group_name;
ServiceGroupConfig group_config;
group_config.name = std::move(group_name);
+ ASSIGN_OR_RETURN(auto group_interval,
+ optionalInterval(group, group_context));
+ ASSIGN_OR_RETURN(auto group_timeout,
+ optionalInteger<std::int64_t>(
+ group, "timeout_second", group_context));
+ if(group_timeout && *group_timeout <= 0)
+ {
+ return std::unexpected(configError(
+ group_context, "timeout_second must be positive"));
+ }
auto services = child(group, "services");
if(!services.readable() || !services.is_map())
{
@@ -399,7 +432,8 @@ mw::E<Configuration> parseTree(ryml::Tree& tree)
}
const auto context = group_context + "." + service_id;
ASSIGN_OR_RETURN(auto service_config,
- parseService(service, service_id, context));
+ parseService(service, service_id, context,
+ group_interval, group_timeout));
if(!service_ids.insert(service_config.id).second)
{
return std::unexpected(configError(
diff --git a/tests/configuration_test.cpp b/tests/configuration_test.cpp
index 93e40c2..4d30c03 100644
--- a/tests/configuration_test.cpp
+++ b/tests/configuration_test.cpp
@@ -40,6 +40,10 @@ TEST(Configuration, LoadsGroupedProtocolSettings)
"socket_permission: 0o660\n"
"groups:\n"
" - name: Public services\n"
+ " interval:\n"
+ " value: 5\n"
+ " unit: minute\n"
+ " timeout_second: 11\n"
" services:\n"
" blog:\n"
" name: Blog\n"
@@ -76,9 +80,6 @@ TEST(Configuration, LoadsGroupedProtocolSettings)
" payload: ping\n"
" gateway:\n"
" name: Gateway\n"
- " interval:\n"
- " value: 1\n"
- " unit: day\n"
" endpoint:\n"
" protocol: icmp\n"
" host: 192.0.2.1\n");
@@ -111,7 +112,9 @@ TEST(Configuration, LoadsGroupedProtocolSettings)
ASSERT_TRUE(std::holds_alternative<UdpEndpoint>(services[2].endpoint));
EXPECT_EQ(std::get<UdpEndpoint>(services[2].endpoint).payload, "ping");
- EXPECT_EQ(services[3].interval, std::chrono::hours(24));
+ EXPECT_EQ(services[2].timeout, std::chrono::seconds(11));
+ EXPECT_EQ(services[3].interval, std::chrono::minutes(5));
+ EXPECT_EQ(services[3].timeout, std::chrono::seconds(11));
ASSERT_TRUE(std::holds_alternative<IcmpEndpoint>(services[3].endpoint));
EXPECT_TRUE(services[3].description.empty());
}
@@ -148,6 +151,18 @@ TEST(Configuration, RejectsInvalidValues)
"database_path: db\n"
"groups:\n"
" - name: g\n"
+ " interval: {value: 0, unit: minute}\n"
+ " services: {}\n",
+ "worker_count: 1\n"
+ "database_path: db\n"
+ "groups:\n"
+ " - name: g\n"
+ " timeout_second: 0\n"
+ " services: {}\n",
+ "worker_count: 1\n"
+ "database_path: db\n"
+ "groups:\n"
+ " - name: g\n"
" services:\n"
" x:\n"
" name: x\n"
diff --git a/tests/scheduler_test.cpp b/tests/scheduler_test.cpp
index 1017acf..1d7d540 100644
--- a/tests/scheduler_test.cpp
+++ b/tests/scheduler_test.cpp
@@ -366,6 +366,23 @@ struct BlockingFactory
}
};
+struct FirstBlockingFactory
+{
+ std::shared_future<void> release;
+ std::promise<void>& started;
+ std::atomic<int>& calls;
+
+ mw::E<std::unique_ptr<ProbeInterface>> operator()(
+ [[maybe_unused]] const Service& service) const
+ {
+ if(calls.fetch_add(1) == 0)
+ {
+ return std::make_unique<BlockingProbe>(release, started);
+ }
+ return makeFake(service);
+ }
+};
+
TEST(Scheduler, SkipsInFlightAndFinishesAcceptedWorkOnStop)
{
WatchingSource source;
@@ -410,26 +427,38 @@ TEST(Scheduler, WaitsForCapacityWithoutWaitingForNextInterval)
std::stop_source stop;
auto release = std::make_unique<std::promise<void>>();
Configuration configuration{1, ":memory:", {{"Group", {
- serviceConfig("a_slow"), serviceConfig("retry"),
- serviceConfig("third")
+ serviceConfig("first"), serviceConfig("second")
}}}};
- auto scheduler = Scheduler::create(configuration, source, BlockingFactory{
- release->get_future().share(), started, calls});
+ auto scheduler = Scheduler::create(
+ configuration, source, FirstBlockingFactory{
+ release->get_future().share(), started, calls});
ASSERT_TRUE(scheduler);
auto running = std::async(std::launch::async, &Scheduler::run,
scheduler->get(), stop.get_token());
auto release_on_exit = std::move(release);
StopOnExit cleanup{stop};
ASSERT_EQ(ready.wait_for(1s), std::future_status::ready);
- EXPECT_FALSE(source.waitForRecords("retry", 1, 20ms));
+ const auto* first = (*scheduler)->findService("first");
+ const auto* second = (*scheduler)->findService("second");
+ const auto deadline = Scheduler::Clock::now() + 1s;
+ while((!first->in_flight || !second->in_flight) &&
+ Scheduler::Clock::now() < deadline)
+ {
+ std::this_thread::yield();
+ }
+ ASSERT_TRUE(first->in_flight);
+ ASSERT_TRUE(second->in_flight);
+ EXPECT_FALSE(source.waitForRecords("first", 1, 20ms));
+ EXPECT_FALSE(source.waitForRecords("second", 1, 20ms));
release_on_exit->set_value();
- ASSERT_TRUE(source.waitForRecords("retry", 1));
- ASSERT_TRUE(source.waitForRecords("third", 1));
+ ASSERT_TRUE(source.waitForRecords("first", 1));
+ ASSERT_TRUE(source.waitForRecords("second", 1));
stop.request_stop();
ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
EXPECT_TRUE(running.get());
- EXPECT_FALSE((*scheduler)->findService("retry")->in_flight.load());
- EXPECT_EQ(calls.load(), 1);
+ EXPECT_FALSE(first->in_flight.load());
+ EXPECT_FALSE(second->in_flight.load());
+ EXPECT_EQ(calls.load(), 2);
}
TEST(Scheduler, CancelsPendingSubmissionOnStopOrError)
@@ -447,10 +476,10 @@ TEST(Scheduler, CancelsPendingSubmissionOnStopOrError)
std::stop_source stop;
auto release = std::make_unique<std::promise<void>>();
Configuration configuration{1, ":memory:", {{"Group", {
- serviceConfig("a_slow"), serviceConfig("pending")
+ serviceConfig("first"), serviceConfig("second")
}}}};
auto scheduler = Scheduler::create(
- configuration, source, BlockingFactory{
+ configuration, source, FirstBlockingFactory{
release->get_future().share(), started, calls});
ASSERT_TRUE(scheduler);
auto running = std::async(std::launch::async, &Scheduler::run,
@@ -458,30 +487,36 @@ TEST(Scheduler, CancelsPendingSubmissionOnStopOrError)
auto release_on_exit = std::move(release);
StopOnExit cleanup{stop};
ASSERT_EQ(ready.wait_for(1s), std::future_status::ready);
- const auto* pending = (*scheduler)->findService("pending");
+ const auto* first = (*scheduler)->findService("first");
+ const auto* second = (*scheduler)->findService("second");
const auto deadline = Scheduler::Clock::now() + 1s;
- while(!pending->in_flight && Scheduler::Clock::now() < deadline)
+ while((!first->in_flight || !second->in_flight) &&
+ Scheduler::Clock::now() < deadline)
{
std::this_thread::yield();
}
- ASSERT_TRUE(pending->in_flight);
+ ASSERT_TRUE(first->in_flight);
+ ASSERT_TRUE(second->in_flight);
if(!fail_save)
{
stop.request_stop();
const auto cancelled_by = Scheduler::Clock::now() + 1s;
- while(pending->in_flight &&
+ while(first->in_flight && second->in_flight &&
Scheduler::Clock::now() < cancelled_by)
{
std::this_thread::yield();
}
- EXPECT_FALSE(pending->in_flight);
+ EXPECT_TRUE(!first->in_flight || !second->in_flight);
EXPECT_EQ(running.wait_for(20ms), std::future_status::timeout);
}
release_on_exit->set_value();
ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
EXPECT_EQ(running.get().has_value(), !fail_save);
- EXPECT_FALSE(pending->in_flight);
- EXPECT_FALSE(source.latest("pending").value());
+ EXPECT_FALSE(first->in_flight);
+ EXPECT_FALSE(second->in_flight);
+ const auto first_saved = source.latest("first").value().has_value();
+ const auto second_saved = source.latest("second").value().has_value();
+ EXPECT_EQ(first_saved + second_saved, fail_save ? 0 : 1);
EXPECT_EQ(source.save_attempts.load(), 1);
}
}