From ef3bbf0859f345d6eb41de1e925307e8a5608ed5 Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Thu, 28 May 2026 11:22:15 +0200 Subject: [PATCH 1/2] feat: infix-schedule based on ietf-schedule(basic) implementation --- .../rootfs/etc/profile.d/update-check.sh | 3 + .../common/rootfs/usr/sbin/infix-check-update | 58 ++ package/confd/confd.mk | 2 +- package/confd/crond.conf | 2 + src/confd/src/Makefile.am | 1 + src/confd/src/core.c | 14 + src/confd/src/core.h | 4 + src/confd/src/schedule.c | 225 +++++ src/confd/yang/confd.inc | 2 + .../yang/confd/ietf-schedule@2026-03-10.yang | 868 ++++++++++++++++++ src/confd/yang/confd/infix-schedule.yang | 1 + .../yang/confd/infix-schedule@2026-05-27.yang | 108 +++ .../yang/confd/infix-system-software.yang | 60 ++ .../infix-system-software@2026-05-29.yang | 290 ++++++ test/case/system/all.yaml | 3 + test/case/system/schedule_reboot/Readme.adoc | 1 + test/case/system/schedule_reboot/test.adoc | 22 + test/case/system/schedule_reboot/test.py | 47 + test/case/system/schedule_reboot/topology.dot | 23 + test/case/system/schedule_reboot/topology.svg | 33 + 20 files changed, 1766 insertions(+), 1 deletion(-) create mode 100644 board/common/rootfs/etc/profile.d/update-check.sh create mode 100755 board/common/rootfs/usr/sbin/infix-check-update create mode 100644 package/confd/crond.conf create mode 100644 src/confd/src/schedule.c create mode 100644 src/confd/yang/confd/ietf-schedule@2026-03-10.yang create mode 120000 src/confd/yang/confd/infix-schedule.yang create mode 100644 src/confd/yang/confd/infix-schedule@2026-05-27.yang create mode 100644 src/confd/yang/confd/infix-system-software@2026-05-29.yang create mode 120000 test/case/system/schedule_reboot/Readme.adoc create mode 100644 test/case/system/schedule_reboot/test.adoc create mode 100755 test/case/system/schedule_reboot/test.py create mode 100644 test/case/system/schedule_reboot/topology.dot create mode 100644 test/case/system/schedule_reboot/topology.svg diff --git a/board/common/rootfs/etc/profile.d/update-check.sh b/board/common/rootfs/etc/profile.d/update-check.sh new file mode 100644 index 000000000..20d306403 --- /dev/null +++ b/board/common/rootfs/etc/profile.d/update-check.sh @@ -0,0 +1,3 @@ +if [ -f /run/infix-update ]; then + printf '\n\033[1;33m *** %s ***\033[0m\n\n' "$(cat /run/infix-update)" +fi diff --git a/board/common/rootfs/usr/sbin/infix-check-update b/board/common/rootfs/usr/sbin/infix-check-update new file mode 100755 index 000000000..55d91a38b --- /dev/null +++ b/board/common/rootfs/usr/sbin/infix-check-update @@ -0,0 +1,58 @@ +#!/bin/sh +# Check for available firmware updates and notify on login if one exists. +# Called by the scheduler when predefined-action infix-schedule:check-update fires. + +NOTIFY_FILE=/run/infix-update +TAG=infix-update + +# Source os-release for VERSION and IMAGE_ID +if [ ! -f /etc/os-release ]; then + logger -t "$TAG" "ERROR: /etc/os-release not found" + exit 1 +fi +. /etc/os-release + +# Dev/dirty builds have no comparable semver — always show the latest release +IS_RELEASE=true +if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then + IS_RELEASE=false +fi + +# Read configured update-url from sysrepo, fall back to upstream +UPDATE_URL=$(sysrepocfg -d running -f json \ + -x '/ietf-system:system/infix-system:software/check-update/update-url' \ + 2>/dev/null \ + | grep -o '"update-url": *"[^"]*"' \ + | grep -o '"[^"]*"$' \ + | tr -d '"') +UPDATE_URL=${UPDATE_URL:-"https://github.com/kernelkit/infix"} + +# Derive API URL from the configured update URL. +# Default (github.com): https://github.com/org/repo → https://api.github.com/repos/org/repo +REPO=$(echo "$UPDATE_URL" | sed 's|https://github.com/||; s|/*$||') +API_URL="https://api.github.com/repos/${REPO}/releases/latest" + +LATEST_TAG=$(curl -sSL --max-time 10 "$API_URL" 2>/dev/null \ + | sed -n 's/.*"tag_name" *: *"\([^"]*\)".*/\1/p' \ + | head -1) +if [ -z "$LATEST_TAG" ]; then + logger -p daemon.info -t "$TAG" "Update check skipped: could not reach ${API_URL}" + exit 0 +fi +LATEST=${LATEST_TAG#v} + +# Compare: is $1 strictly newer than $2? +newer() { + [ "$1" = "$2" ] && return 1 + [ "$(printf '%s\n%s' "$1" "$2" | sort -V | tail -1)" = "$1" ] +} + +if [ "$IS_RELEASE" = false ] || newer "$LATEST" "$VERSION"; then + BUNDLE_URL="${UPDATE_URL}/releases/download/${LATEST_TAG}/${IMAGE_ID}-${LATEST}.tar.gz" + MSG="Firmware update available: ${LATEST_TAG}, running ${VERSION} (see ${BUNDLE_URL})" + logger -t "$TAG" "$MSG — ${BUNDLE_URL}" + printf '%s\n' "$MSG" > "$NOTIFY_FILE" +else + logger -p daemon.debug -t "$TAG" "No update available (current: $VERSION, latest: $LATEST)" + rm -f "$NOTIFY_FILE" +fi diff --git a/package/confd/confd.mk b/package/confd/confd.mk index ccb50d2b0..76ac6f26d 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -36,7 +36,7 @@ else CONFD_CONF_OPTS += --disable-gps endif define CONFD_INSTALL_EXTRA - for fn in confd.conf resolvconf.conf; do \ + for fn in confd.conf crond.conf resolvconf.conf; do \ cp $(CONFD_PKGDIR)/$$fn $(FINIT_D)/available/; \ ln -sf ../available/$$fn $(FINIT_D)/enabled/$$fn; \ done diff --git a/package/confd/crond.conf b/package/confd/crond.conf new file mode 100644 index 000000000..abd599a3d --- /dev/null +++ b/package/confd/crond.conf @@ -0,0 +1,2 @@ +# Cron daemon for infix-schedule +service [S12345] crond -f -- Cron daemon diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index 447117994..07c53abd7 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -48,6 +48,7 @@ confd_plugin_la_SOURCES = \ if-wireguard.c \ keystore.c \ system.c \ + schedule.c \ ntp.c \ ptp.c \ syslog.c \ diff --git a/src/confd/src/core.c b/src/confd/src/core.c index dfd0261d4..af9ad697c 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -527,6 +527,10 @@ static int change_cb(sr_session_ctx_t *session, uint32_t sub_id, const char *mod if ((rc = system_change(session, config, diff, event, confd))) goto free_diff; + /* infix-schedule */ + if ((rc = schedule_change(session, config, diff, event, confd))) + goto free_diff; + /* infix-containers */ #ifdef CONTAINERS if ((rc = containers_change(session, config, diff, event, confd))) @@ -700,6 +704,11 @@ int sr_plugin_init_cb(sr_session_ctx_t *session, void **priv) ERROR("Failed to subscribe to ietf-hardware"); goto err; } + rc = subscribe_model("infix-schedule", &confd, 0); + if (rc) { + ERROR("Failed to subscribe to infix-schedule"); + goto err; + } rc = subscribe_model("infix-firewall", &confd, 0); if (rc) { ERROR("Failed to subscribe to infix-firewall"); @@ -764,6 +773,11 @@ int sr_plugin_init_cb(sr_session_ctx_t *session, void **priv) rc = ntp_candidate_init(&confd); if (rc) goto err; + + rc = schedule_init(&confd); + if (rc) + goto err; + /* YOUR_INIT GOES HERE */ return SR_ERR_OK; diff --git a/src/confd/src/core.h b/src/confd/src/core.h index b56c8bf32..2df53e1e7 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -215,6 +215,10 @@ int system_rpc_init (struct confd *confd); int hostnamefmt (struct confd *confd, const char *fmt, char *hostnm, size_t hostlen, char *domain, size_t domlen); int system_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); +/* schedule.c */ +int schedule_init(struct confd *confd); +int schedule_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); + /* containers.c */ #ifdef CONTAINERS int containers_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); diff --git a/src/confd/src/schedule.c b/src/confd/src/schedule.c new file mode 100644 index 000000000..ec5ef89a9 --- /dev/null +++ b/src/confd/src/schedule.c @@ -0,0 +1,225 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include + +#include +#include +#include +#include +#include "core.h" + +#define XPATH_BASE "/ietf-system:system/infix-schedule:schedules" +#define CRONTAB_FILE "/var/spool/cron/crontabs/root" + +static const char *action_to_cmd(const char *action) +{ + if (!action) + return NULL; + if (strstr(action, "reboot")) + return "/usr/sbin/reboot"; + if (strstr(action, "check-update")) + return "/usr/sbin/infix-check-update"; + return NULL; +} + +/* + * Convert ietf-schedule recurrence to a 5-field cron expression. + * + * Frequency mapping: + * secondly → * * * * * (cron minimum is 1 minute) + * minutely/N → *\/N * * * * + * hourly/N → 0 *\/N * * * + * daily/N → 0 0 *\/N * * + * weekly/N → 0 0 * * *\/N + * monthly/N → 0 0 1 *\/N * + * + * Optional by-* leaves refine the expression: + * byminute → replaces the minute field + * byhour → replaces the hour field + * byday → replaces the day-of-week field + */ +static void build_cron_expr(struct lyd_node *recurrence, char *expr, size_t sz) +{ + const char *freq, *ivstr; + struct lyd_node *node; + char min[64], hr[64], dom[64], mon[64], dow[64]; + int iv, first; + + snprintf(min, sizeof(min), "*"); + snprintf(hr, sizeof(hr), "*"); + snprintf(dom, sizeof(dom), "*"); + snprintf(mon, sizeof(mon), "*"); + snprintf(dow, sizeof(dow), "*"); + + freq = lydx_get_cattr(recurrence, "frequency"); + ivstr = lydx_get_cattr(recurrence, "interval"); + if (!freq || !ivstr) + goto done; + + iv = atoi(ivstr); + if (iv <= 0) + iv = 1; + + if (strstr(freq, "secondly")) { + /* Sub-minute intervals cannot be expressed in standard cron. + * Map to every minute as the finest available granularity. */ + snprintf(min, sizeof(min), "*"); + } else if (strstr(freq, "minutely")) { + if (iv == 1) + snprintf(min, sizeof(min), "*"); + else + snprintf(min, sizeof(min), "*/%d", iv); + } else if (strstr(freq, "hourly")) { + snprintf(min, sizeof(min), "0"); + if (iv == 1) + snprintf(hr, sizeof(hr), "*"); + else + snprintf(hr, sizeof(hr), "*/%d", iv); + } else if (strstr(freq, "daily")) { + snprintf(min, sizeof(min), "0"); + snprintf(hr, sizeof(hr), "0"); + if (iv > 1) + snprintf(dom, sizeof(dom), "*/%d", iv); + } else if (strstr(freq, "weekly")) { + snprintf(min, sizeof(min), "0"); + snprintf(hr, sizeof(hr), "0"); + if (iv == 1) + snprintf(dow, sizeof(dow), "*"); + else + snprintf(dow, sizeof(dow), "*/%d", iv); + } else if (strstr(freq, "monthly")) { + snprintf(min, sizeof(min), "0"); + snprintf(hr, sizeof(hr), "0"); + snprintf(dom, sizeof(dom), "1"); + if (iv > 1) + snprintf(mon, sizeof(mon), "*/%d", iv); + } + + /* byminute: override minute field with explicit list */ + first = 1; + LYX_LIST_FOR_EACH(lyd_child(recurrence), node, "byminute") { + const char *val = lyd_get_value(node); + if (!val) continue; + if (first) { snprintf(min, sizeof(min), "%s", val); first = 0; } + else strncat(min, ",", sizeof(min) - strlen(min) - 1), + strncat(min, val, sizeof(min) - strlen(min) - 1); + } + + /* byhour: override hour field with explicit list */ + first = 1; + LYX_LIST_FOR_EACH(lyd_child(recurrence), node, "byhour") { + const char *val = lyd_get_value(node); + if (!val) continue; + if (first) { snprintf(hr, sizeof(hr), "%s", val); first = 0; } + else strncat(hr, ",", sizeof(hr) - strlen(hr) - 1), + strncat(hr, val, sizeof(hr) - strlen(hr) - 1); + } + + /* byday: override day-of-week field */ + first = 1; + LYX_LIST_FOR_EACH(lyd_child(recurrence), node, "byday") { + const char *val = lydx_get_cattr(node, "weekday"); + const char *num = NULL; + if (!val) continue; + /* map YANG weekday names to cron numbers (0=sunday) */ + if (!strcmp(val, "sunday")) num = "0"; + else if (!strcmp(val, "monday")) num = "1"; + else if (!strcmp(val, "tuesday")) num = "2"; + else if (!strcmp(val, "wednesday")) num = "3"; + else if (!strcmp(val, "thursday")) num = "4"; + else if (!strcmp(val, "friday")) num = "5"; + else if (!strcmp(val, "saturday")) num = "6"; + if (!num) continue; + if (first) { snprintf(dow, sizeof(dow), "%s", num); first = 0; } + else strncat(dow, ",", sizeof(dow) - strlen(dow) - 1), + strncat(dow, num, sizeof(dow) - strlen(dow) - 1); + } + +done: + snprintf(expr, sz, "%s %s %s %s %s", min, hr, dom, mon, dow); +} + +static void reload_crond(void) +{ + char *args[] = { "pkill", "-HUP", "crond", NULL }; + + runbg(args, 0); +} + +static void apply_schedules(struct lyd_node *config) +{ + struct lyd_node *schedules, *sched; + FILE *fp; + int count = 0; + + makepath("/var/spool/cron/crontabs"); + fp = fopen(CRONTAB_FILE, "w"); + if (!fp) { + ERROR("schedule: failed to open %s", CRONTAB_FILE); + return; + } + fprintf(fp, "# Managed by infix-schedule\n"); + + schedules = config ? lydx_get_xpathf(config, XPATH_BASE) : NULL; + if (!schedules) + goto out; + + LYX_LIST_FOR_EACH(lyd_child(schedules), sched, "schedule") { + struct lyd_node *recurrence; + const char *name, *action, *cmd; + char expr[128]; + + if (!lydx_is_enabled(sched, "enabled")) + continue; + + name = lydx_get_cattr(sched, "name"); + + recurrence = lydx_get_child(sched, "recurrence"); + if (!recurrence) + continue; + + build_cron_expr(recurrence, expr, sizeof(expr)); + + action = lydx_get_cattr(sched, "predefined-action"); + cmd = action_to_cmd(action); + if (!cmd) + continue; + + fprintf(fp, "# %s\n%s %s\n", name ?: "unnamed", expr, cmd); + NOTE("schedule: %s → cron '%s %s'", name ?: "unnamed", expr, cmd); + count++; + } + +out: + fclose(fp); + reload_crond(); + NOTE("schedule: %d active job(s) written to crontab", count); +} + +int schedule_change(sr_session_ctx_t *session, struct lyd_node *config, + struct lyd_node *diff, sr_event_t event, struct confd *confd) +{ + if (event != SR_EV_DONE) + return SR_ERR_OK; + + apply_schedules(config); + return SR_ERR_OK; +} + +int schedule_init(struct confd *confd) +{ + sr_data_t *data = NULL; + + /* + * Sync the crontab with current running config at startup so + * scheduled jobs survive across reboots. + */ + sr_get_data(confd->session, "//.", 0, 0, 0, &data); + apply_schedules(data ? data->tree : NULL); + if (data) + sr_release_data(data); + + return SR_ERR_OK; +} diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index d8d45592e..146fd3365 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -56,4 +56,6 @@ MODULES=( "ieee1588-ptp-tt@2023-08-14.yang -e timestamp-correction" "ieee802-dot1as-gptp@2025-12-10.yang" "infix-ptp@2026-04-07.yang" + "ietf-schedule@2026-03-10.yang -e basic-recurrence" + "infix-schedule@2026-05-27.yang" ) diff --git a/src/confd/yang/confd/ietf-schedule@2026-03-10.yang b/src/confd/yang/confd/ietf-schedule@2026-03-10.yang new file mode 100644 index 000000000..128180f9b --- /dev/null +++ b/src/confd/yang/confd/ietf-schedule@2026-03-10.yang @@ -0,0 +1,868 @@ +module ietf-schedule { + yang-version 1.1; + namespace "urn:ietf:params:xml:ns:yang:ietf-schedule"; + prefix schedule; + + import ietf-yang-types { + prefix yang; + reference + "RFC 9911: Common YANG Data Types"; + } + + import ietf-system { + prefix sys; + reference + "RFC 7317: A YANG Data Model for System Management"; + } + + organization + "IETF NETMOD Working Group"; + contact + "WG Web: + WG List: + + Editor: Qiufang Ma + + Author: Qin Wu + + Editor: Mohamed Boucadair + + Author: Daniel King + "; + description + "This YANG module defines a set of common types and groupings + that are applicable for scheduling purposes, such as events, + policies, services, or resources based on date and time. + + The key words 'MUST', 'MUST NOT', 'REQUIRED', 'SHALL', 'SHALL + NOT', 'SHOULD', 'SHOULD NOT', 'RECOMMENDED', 'NOT RECOMMENDED', + 'MAY', and 'OPTIONAL' in this document are to be interpreted as + described in BCP 14 (RFC 2119) (RFC 8174) when, and only when, + they appear in all capitals, as shown here. + + Copyright (c) 2026 IETF Trust and the persons identified + as authors of the code. All rights reserved. + + Redistribution and use in source and binary forms, with + or without modification, is permitted pursuant to, and + subject to the license terms contained in, the Revised + BSD License set forth in Section 4.c of the IETF Trust's + Legal Provisions Relating to IETF Documents + (https://trustee.ietf.org/license-info). + + This version of this YANG module is part of RFC 9922; see + the RFC itself for full legal notices. + + All revisions of IETF and IANA-maintained modules can be found + in the 'YANG Parameters' registry group + (https://www.iana.org/assignments/yang-parameters)."; + + revision 2026-03-10 { + description + "Initial revision."; + reference + "RFC 9922: A Common YANG Data Model for Scheduling"; + } + + feature basic-recurrence { + description + "Indicates that the server supports configuring a basic + scheduled recurrence."; + } + + feature icalendar-recurrence { + description + "Indicates that the server supports configuring a comprehensive + scheduled iCalendar recurrence."; + reference + "RFC 5545: Internet Calendaring and Scheduling Core Object + Specification (iCalendar), + Sections 3.3.10 and 3.8.5"; + } + + typedef weekday { + type enumeration { + enum sunday { + value 0; + description + "Sunday of the week."; + } + enum monday { + value 1; + description + "Monday of the week."; + } + enum tuesday { + value 2; + description + "Tuesday of the week."; + } + enum wednesday { + value 3; + description + "Wednesday of the week."; + } + enum thursday { + value 4; + description + "Thursday of the week."; + } + enum friday { + value 5; + description + "Friday of the week."; + } + enum saturday { + value 6; + description + "Saturday of the week."; + } + } + description + "Seven days of the week."; + } + + typedef duration { + type string { + pattern '((\+)?|\-)P((([0-9]+)D)?(T(0[0-9]|1[0-9]|2[0-3])' + + ':[0-5][0-9]:[0-5][0-9]))|P([0-9]+)W'; + } + description + "Duration of the time. The format can represent nominal + durations (weeks designated by 'W' and days designated by 'D') + and accurate durations (hours:minutes:seconds follows the + designator 'T'). + + Note that this value type doesn't support the 'Y' and 'M' + designators to specify durations in terms of years and months. + + Negative durations are typically used to schedule an alarm to + trigger before an associated time."; + reference + "RFC 5545: Internet Calendaring and Scheduling Core Object + Specification (iCalendar), Sections 3.3.6 and + 3.8.6.3"; + } + + identity schedule-type { + description + "Base identity for schedule type."; + } + + identity one-shot { + base schedule-type; + description + "Indicates a one-shot schedule. That is a schedule that + will trigger an action with the duration being specified as + 0 or end time being specified as the same as the start time, + and then the schedule will disable itself."; + } + + identity period { + base schedule-type; + description + "Indicates a period-based schedule consisting of either a + start and end or a start and positive duration of time. If + neither an end nor a duration is indicated, the period is + considered to last forever."; + } + + identity recurrence { + base schedule-type; + description + "Indicates a recurrence-based schedule."; + } + + identity frequency-type { + description + "Base identity for frequency type."; + } + + identity secondly { + base frequency-type; + description + "Indicates a repeating rule based on an interval of + a second or more."; + } + + identity minutely { + base frequency-type; + description + "Indicates a repeating rule based on an interval of + a minute or more."; + } + + identity hourly { + base frequency-type; + description + "Indicates a repeating rule based on an interval of + an hour or more."; + } + + identity daily { + base frequency-type; + description + "Indicates a repeating rule based on an interval of + a day or more."; + } + + identity weekly { + base frequency-type; + description + "Indicates a repeating rule based on an interval of + a week or more."; + } + + identity monthly { + base frequency-type; + description + "Indicates a repeating rule based on an interval of + a month or more."; + } + + identity yearly { + base frequency-type; + description + "Indicates a repeating rule based on an interval of + a year or more."; + } + + identity schedule-state { + description + "Base identity for schedule state."; + } + + identity enabled { + base schedule-state; + description + "Indicates a schedule with an enabled state."; + } + + identity finished { + base schedule-state; + description + "Indicates a schedule with a finished state. + The finished state indicates that the schedule has ended."; + } + + identity disabled { + base schedule-state; + description + "Indicates a schedule with a disabled state."; + } + + identity out-of-date { + base schedule-state; + description + "Indicates a schedule that is received out-of-date."; + } + + identity conflicted { + base schedule-state; + description + "Indicates a schedule with a conflicted state with other + schedules."; + } + + identity discard-action-type { + description + "Base identity for the action for the responder to take + when a requested schedule cannot be accepted for any + reason and is discarded."; + } + + identity warning { + base discard-action-type; + description + "Indicates that a warning message is generated + when a schedule is discarded."; + } + + identity error { + base discard-action-type; + description + "Indicates that an error message is generated + when a schedule is discarded."; + } + + identity silently-discard { + base discard-action-type; + description + "Indicates that a schedule that is not valid is silently + discarded."; + } + + grouping generic-schedule-params { + description + "Includes a set of generic parameters that are followed by + the entity that supports schedules. + + Such parameters are used as guards to prevent, e.g., stale + configuration."; + leaf description { + type string; + description + "Provides a description of the schedule."; + } + leaf time-zone-identifier { + type sys:timezone-name; + description + "Indicates the identifier for the time zone. This parameter + MUST be specified if any of the date and time values are + in the format of local time. It MUST NOT be applied to + date and time values that are specified in the format of + UTC or time zone offset to UTC."; + } + leaf validity { + type yang:date-and-time; + description + "Specifies the date and time after which a schedule will not + be considered as valid. This parameter takes precedence + over similar attributes that are provided at the schedule + instance itself."; + } + leaf max-allowed-start { + type yang:date-and-time; + description + "Specifies the maximum scheduled start date and time. + A requested schedule whose first instance occurs after + this value cannot be accepted by the entity. Specifically, + a requested schedule will be rejected if the first + occurrence of that schedule exceeds 'max-allowed-start'."; + } + leaf min-allowed-start { + type yang:date-and-time; + description + "Specifies the minimum scheduled start date and time. + A requested schedule whose first instance occurs before + this value cannot be accepted by the entity. Specifically, + a requested schedule will be rejected if the first + occurrence of that schedule is scheduled before + 'min-allowed-start'."; + } + leaf max-allowed-end { + type yang:date-and-time; + description + "A requested schedule will be rejected if the end time of + the last occurrence exceeds 'max-allowed-end'."; + } + leaf discard-action { + type identityref { + base discard-action-type; + } + description + "Specifies the behavior when a schedule is discarded for + any reason, e.g., failing to satisfy the guards in this + grouping or being received out-of-date."; + } + } + + grouping period-of-time { + description + "This grouping is defined for the period of time property."; + reference + "RFC 5545: Internet Calendaring and Scheduling Core Object + Specification (iCalendar), Section 3.3.9"; + leaf period-description { + type string; + description + "Provides a description of the period."; + } + leaf period-start { + type yang:date-and-time; + description + "Period start time."; + } + leaf time-zone-identifier { + type sys:timezone-name; + description + "Indicates the identifier for the time zone. This parameter + MUST be specified if either the 'period-start' or + 'period-end' value is reported in local time format. + It MUST NOT be applied to date and time values that are + specified in the format of UTC or time zone offset + to UTC."; + } + choice period-type { + description + "Indicates the type of the time period. Two types are + supported. If no choice is indicated, the period is + considered to last forever."; + case explicit { + description + "A period of time is identified by its start and its end. + 'period-start' indicates the period start."; + leaf period-end { + type yang:date-and-time; + description + "A period of time is defined by a start and end time. + The start MUST be no later than the end. The period + is considered as a one-shot schedule if the end time + is the same as the start time."; + } + } + case duration { + description + "A period of time is defined by a start and a non-negative + duration of time."; + leaf duration { + type duration { + pattern 'P((([0-9]+)D)?(T(0[0-9]|1[0-9]|2[0-3])' + + ':[0-5][0-9]:[0-5][0-9]))|P([0-9]+)W'; + } + description + "A non-negative duration of time. This value is + equivalent to the format of 'duration' type except that + the value cannot be negative. The period is considered + to be a one-shot schedule if the value is 0."; + } + } + } + } + + grouping recurrence-basic { + description + "A simple definition of recurrence."; + leaf recurrence-description { + type string; + description + "Provides a description of the recurrence."; + } + leaf frequency { + type identityref { + base frequency-type; + } + description + "Specifies the frequency type of the recurrence rule."; + } + leaf interval { + type uint32 { + range "1..max"; + } + must '../frequency' { + error-message "Frequency must be provided."; + } + description + "A positive integer representing the interval at which the + recurrence rule repeats. For example, within a 'daily' + recurrence rule, a value of '8' means every eight days."; + } + } + + grouping recurrence-utc { + description + "A simple definition of recurrence with time specified in + UTC format."; + container recurrence-first { + description + "Specifies the first instance of the recurrence. If + unspecified, the recurrence is considered to start from + the date and time when the recurrence pattern is first + satisfied."; + leaf start-time-utc { + type yang:date-and-time; + description + "Defines the date and time of the first instance + in the recurrence set. A UTC format MUST be used."; + } + leaf duration { + type uint32; + units "seconds"; + description + "When specified, it indicates how long the first occurrence + lasts. Unless specified otherwise, it also applies to all + the other instances in the recurrence set."; + } + } + choice recurrence-end { + description + "Modes to control the end of a recurrence rule. If no + choice is indicated, the recurrence rule is considered + to repeat forever."; + case until { + description + "This case defines a way that limits the end of + a recurrence rule in an inclusive manner."; + leaf utc-until { + type yang:date-and-time; + description + "This parameter specifies a date and time value to + inclusively terminate the recurrence in UTC format. + That is, if the value specified by this parameter is + synchronized with the specified recurrence rule, it + becomes the last instance of the recurrence rule."; + } + } + case count { + description + "This case defines the number of occurrences at which + to terminate the recurrence rule."; + leaf count { + type uint32 { + range "1..max"; + } + description + "The positive number of occurrences at which to + terminate the recurrence rule."; + } + } + } + uses recurrence-basic; + } + + grouping recurrence-with-time-zone { + description + "A simple definition of recurrence to specify a recurrence + rule with a time zone."; + container recurrence-first { + description + "Specifies the first instance of the recurrence. If + unspecified, the recurrence is considered to start from + the date and time when the recurrence pattern is first + satisfied."; + leaf start-time { + type yang:date-and-time; + description + "Defines the date and time of the first instance + in the recurrence set."; + } + leaf duration { + type duration; + description + "When specified, it indicates how long the first + occurrence lasts. Unless specified otherwise, it also + applies to all the other instances in the recurrence + set."; + } + } + leaf time-zone-identifier { + type sys:timezone-name; + description + "Indicates the identifier for the time zone in a time + zone database. This parameter MUST be specified if either + the 'start-time' or 'until' value is reported in local + time format. It MUST NOT be applied to date and time + values that are specified in the format of UTC or time + zone offset to UTC."; + } + choice recurrence-end { + description + "Modes to terminate the recurrence rule. If no choice is + indicated, the recurrence rule is considered to repeat + forever."; + case until { + description + "The end of the recurrence rule is indicated by a specific + date-and-time value in an inclusive manner."; + leaf until { + type yang:date-and-time; + description + "Specifies a date and time value to inclusively terminate + the recurrence. That is, if the value specified by + this parameter is synchronized with the specified + recurrence, it becomes the last instance of the + recurrence."; + } + } + case count { + description + "The end of the recurrence is indicated by the number + of occurrences."; + leaf count { + type uint32 { + range "1..max"; + } + description + "The positive number of occurrences at which to + terminate the recurrence."; + } + } + } + uses recurrence-basic; + } + + grouping recurrence-utc-with-periods { + description + "This grouping defines an aggregate set of repeating + occurrences with UTC time format. The recurrence instances + are specified by the occurrences defined by both the + recurrence rule and 'period-timeticks' list. Duplicate + instances are ignored."; + uses recurrence-utc; + list period-timeticks { + key "period-start"; + description + "A list of periods with timeticks formats."; + leaf period-start { + type yang:timeticks; + must "(not(derived-from-or-self(../../frequency," + + "'schedule:secondly')) or (current() < 100)) and " + + "(not(derived-from-or-self(../../frequency," + + "'schedule:minutely')) or (current() < 6000)) and " + + "(not(derived-from-or-self(../../frequency," + + "'schedule:hourly')) or (current() < 360000)) and " + + "(not(derived-from-or-self(../../frequency," + + "'schedule:daily')) or (current() < 8640000)) and " + + "(not(derived-from-or-self(../../frequency," + + "'schedule:weekly')) or (current() < 60480000)) and " + + "(not(derived-from-or-self(../../frequency," + + "'schedule:monthly')) or (current() < 267840000)) and " + + "(not(derived-from-or-self(../../frequency," + + "'schedule:yearly')) or (current() < 3162240000))" { + error-message + "The 'period-start' must not exceed the frequency + interval."; + } + description + "Start time of the schedule within one recurrence. + + Given that the value is in timeticks format + (i.e., 1/100 of a second), the values in the must + statement translate to 100 = 1 s (secondly), + 6000 = 60 s = 1 min (minutely), and so on for all + instances in the must statement invariant."; + } + leaf period-end { + type yang:timeticks; + description + "End time of the schedule within one recurrence. + The period start MUST be no later than the period + end."; + } + } + } + + grouping recurrence-time-zone-with-periods { + description + "This grouping defines an aggregate set of repeating + occurrences with local time format and time zone specified. + The recurrence instances are specified by the occurrences + defined by both the recurrence rule and 'period' list. + Duplicate instances are ignored."; + uses recurrence-with-time-zone; + list period { + key "period-start"; + description + "A list of periods with date-and-time formats."; + uses period-of-time; + } + } + + grouping icalendar-recurrence { + description + "This grouping specifies properties of a recurrence rule."; + reference + "RFC 5545: Internet Calendaring and Scheduling Core Object + Specification (iCalendar), Section 3.8.5"; + uses recurrence-time-zone-with-periods; + leaf-list bysecond { + type uint32 { + range "0..60"; + } + description + "Specifies a list of seconds within a minute."; + } + leaf-list byminute { + type uint32 { + range "0..59"; + } + description + "Specifies a list of minutes within an hour."; + } + leaf-list byhour { + type uint32 { + range "0..23"; + } + description + "Specifies a list of hours of the day."; + } + list byday { + key "weekday"; + description + "Specifies a list of days of the week."; + leaf-list direction { + when "derived-from-or-self(../../frequency, " + + "'schedule:monthly') or " + + "(derived-from-or-self(../../frequency," + + "'schedule:yearly') and not(../../byyearweek))"; + + type int32 { + range "-53..-1|1..53"; + } + description + "When specified, it indicates the nth occurrence of a + specific day within the monthly or yearly recurrence + rule. For example, within a monthly rule, +1 monday + represents the first Monday within the month, whereas + -1 monday represents the last Monday of the month."; + } + leaf weekday { + type schedule:weekday; + description + "Corresponds to seven days of the week."; + } + } + leaf-list bymonthday { + type int32 { + range "-31..-1|1..31"; + } + description + "Specifies a list of days of the month."; + } + leaf-list byyearday { + type int32 { + range "-366..-1|1..366"; + } + description + "Specifies a list of days of the year."; + } + leaf-list byyearweek { + when "derived-from-or-self(../frequency, 'schedule:yearly')"; + type int32 { + range "-53..-1|1..53"; + } + description + "Specifies a list of weeks of the year."; + } + leaf-list byyearmonth { + type uint32 { + range "1..12"; + } + description + "Specifies a list of months of the year."; + } + leaf-list bysetpos { + type int32 { + range "-366..-1|1..366"; + } + description + "Specifies a list of values that corresponds to the nth + occurrence within the set of recurrence instances + specified by the rule. It must only be used in conjunction + with another 'byxxx' (bysecond, byminute, etc.) rule + part."; + } + leaf workweek-start { + type schedule:weekday; + description + "Specifies the day on which the workweek starts."; + } + leaf-list exception-dates { + type yang:date-and-time; + description + "Defines a list of exceptions for recurrence."; + } + } + + grouping schedule-status { + description + "This grouping defines common properties of scheduling + status."; + leaf state { + type identityref { + base schedule-state; + } + description + "Indicates the current state of the schedule."; + } + leaf version { + type uint16; + description + "Indicates the version number of the schedule."; + } + leaf schedule-type { + type identityref { + base schedule-type; + } + description + "Indicates the schedule type."; + } + leaf local-time { + type yang:date-and-time; + config false; + description + "Reports the local time as used by the entity that + hosts the schedule."; + } + leaf last-update { + type yang:date-and-time; + config false; + description + "Reports the timestamp of when the schedule is last + updated."; + } + leaf counter { + when "derived-from-or-self(../schedule-type, " + + "'schedule:recurrence')"; + type yang:counter32; + config false; + description + "The number of occurrences while invoking the scheduled + action successfully. The count wraps around when it reaches + the maximum value."; + } + leaf last-occurrence { + when "derived-from-or-self(../schedule-type, " + + "'schedule:recurrence')"; + type yang:date-and-time; + config false; + description + "Indicates the timestamp of last occurrence."; + } + leaf upcoming-occurrence { + when "derived-from-or-self(../schedule-type, " + + "'schedule:recurrence')" + + "and derived-from-or-self(../state, 'schedule:enabled')"; + type yang:date-and-time; + config false; + description + "Indicates the timestamp of next occurrence."; + } + leaf last-failed-occurrence { + when "derived-from-or-self(../schedule-type, " + + "'schedule:recurrence')"; + type yang:date-and-time; + config false; + description + "Indicates the timestamp of last failed action triggered by + the schedule."; + } + leaf failure-counter { + when "derived-from-or-self(../schedule-type, " + + "'schedule:recurrence')"; + type yang:counter32; + config false; + description + "Counts the number of failures while invoking the scheduled + action."; + } + } + + grouping schedule-status-with-time-zone { + description + "This grouping defines common properties of scheduling + status, including timezone."; + leaf time-zone-identifier { + type sys:timezone-name; + config false; + description + "Indicates the identifier for the time zone in a time + zone database."; + } + uses schedule-status; + } + + grouping schedule-status-with-name { + description + "This grouping defines common properties of scheduling + status, including a schedule name."; + leaf schedule-name { + type string; + description + "The schedule identifier that uniquely identifies a + schedule within a device, controller, network, etc. + The unicity scope depends on the implementation."; + } + uses schedule-status; + } +} \ No newline at end of file diff --git a/src/confd/yang/confd/infix-schedule.yang b/src/confd/yang/confd/infix-schedule.yang new file mode 120000 index 000000000..16f2ae015 --- /dev/null +++ b/src/confd/yang/confd/infix-schedule.yang @@ -0,0 +1 @@ +infix-schedule@2026-05-27.yang \ No newline at end of file diff --git a/src/confd/yang/confd/infix-schedule@2026-05-27.yang b/src/confd/yang/confd/infix-schedule@2026-05-27.yang new file mode 100644 index 000000000..ab3222eab --- /dev/null +++ b/src/confd/yang/confd/infix-schedule@2026-05-27.yang @@ -0,0 +1,108 @@ +module infix-schedule { + yang-version 1.1; + namespace "urn:project:yang:infix-schedule"; + prefix infix-schedule; + + import ietf-system { + prefix sys; + } + import ietf-schedule { + prefix schedule; + } + + organization + "Infix Project"; + contact + "Infix Project"; + description + "This module augments ietf-system with scheduling capabilities."; + + revision 2026-05-27 { + description + "Initial revision."; + } + + identity predefined-action { + description + "Base identity for predefined actions."; + } + + identity reboot { + base predefined-action; + description + "Reboot the system."; + } + + identity check-update { + base predefined-action; + description + "Check for and notify about available firmware updates. + + The update policy is configured under + /ietf-system:system/infix-system:software/check-update."; + } + + augment "/sys:system" { + container schedules { + list schedule { + key "name"; + leaf name { + type string; + } + leaf enabled { + type boolean; + default "true"; + } + leaf description { + type string; + } + choice action { + case predefined { + leaf predefined-action { + type identityref { + base predefined-action; + } + } + } + } + container recurrence { + if-feature "schedule:basic-recurrence"; + uses schedule:recurrence-basic; + leaf-list byhour { + type uint32 { + range "0..23"; + } + } + leaf-list byminute { + type uint32 { + range "0..59"; + } + } + list byday { + key "weekday"; + leaf weekday { + type schedule:weekday; + } + } + leaf-list bymonthday { + type int32 { + range "-31..-1|1..31"; + } + } + leaf-list byyearmonth { + type uint32 { + range "1..12"; + } + } + } + container status { + config false; + uses schedule:schedule-status; + leaf cron-expression { + type string; + } + } + } + } + } +} diff --git a/src/confd/yang/confd/infix-system-software.yang b/src/confd/yang/confd/infix-system-software.yang index 907b4224d..2b26f6702 100644 --- a/src/confd/yang/confd/infix-system-software.yang +++ b/src/confd/yang/confd/infix-system-software.yang @@ -20,6 +20,10 @@ submodule infix-system-software { contact "kernelkit@googlegroups.com"; description "Software status and upgrade."; + revision 2026-05-29 { + description "Add check-update config and update-status operational data"; + reference "Internal"; + } revision 2024-12-16 { description "Add boot-order operational data"; reference "Internal"; @@ -80,6 +84,38 @@ submodule infix-system-software { "The last error encountered by the installer service."; } } + augment "/sys:system" { + container software { + description + "Software management configuration."; + + container check-update { + description + "Policy for automatic firmware update checks. + + When triggered via an infix-schedule:check-update action, + the system checks the configured URL for a newer release and logs + a notification if one is found."; + + leaf enabled { + type boolean; + default false; + description + "Enable automatic update checks."; + } + + leaf update-url { + type string; + default "https://github.com/kernelkit/infix"; + description + "Base URL of the update source. The check script appends + /releases/latest and follows the redirect to determine the + latest release tag. Override for customer-specific channels."; + } + } + } + } + augment "/sys:system-state" { container software { description @@ -112,6 +148,30 @@ submodule infix-system-software { } uses boot-order; + container update-status { + description + "Result of the last unattended update check."; + + leaf available-version { + type string; + description + "Latest available version, e.g. '26.06.0'. Empty when + no update has been found or the check has not run yet."; + } + + leaf url { + type string; + description + "Direct download URL for the available bundle."; + } + + leaf last-check { + type yang:date-and-time; + description + "Timestamp of the most recent update check."; + } + } + container installer { description "The current state of the software installer service."; diff --git a/src/confd/yang/confd/infix-system-software@2026-05-29.yang b/src/confd/yang/confd/infix-system-software@2026-05-29.yang new file mode 100644 index 000000000..2b26f6702 --- /dev/null +++ b/src/confd/yang/confd/infix-system-software@2026-05-29.yang @@ -0,0 +1,290 @@ +submodule infix-system-software { + yang-version 1.1; + belongs-to infix-system { + prefix ixsys; + } + + import ietf-yang-types { + prefix yang; + } + + import ietf-netconf-acm { + prefix nacm; + } + + import ietf-system { + prefix sys; + } + + organization "KernelKit"; + contact "kernelkit@googlegroups.com"; + description "Software status and upgrade."; + + revision 2026-05-29 { + description "Add check-update config and update-status operational data"; + reference "Internal"; + } + revision 2024-12-16 { + description "Add boot-order operational data"; + reference "Internal"; + } + revision 2023-06-27 { + description "Initial revision."; + reference "internal"; + } + + grouping rauc-stage-log { + leaf datetime { + type yang:date-and-time; + description + "The time of the event."; + } + + leaf count { + type uint32; + description + "The total number of occurrences of the event."; + } + } + + grouping boot-order { + leaf-list boot-order { + type enumeration { + enum "primary"; + enum "secondary"; + enum "net"; + } + ordered-by user; + min-elements 1; // At least one value is required + max-elements 3; // Ensure reasonable maximum size if needed + } + } + grouping installer-state { + leaf operation { + type string; + description + "The current operation of the installer service."; + } + + container progress { + leaf percentage { + type uint8 { + range "0 .. 100"; + } + } + + leaf message { + type string; + } + } + + leaf last-error { + type string; + description + "The last error encountered by the installer service."; + } + } + augment "/sys:system" { + container software { + description + "Software management configuration."; + + container check-update { + description + "Policy for automatic firmware update checks. + + When triggered via an infix-schedule:check-update action, + the system checks the configured URL for a newer release and logs + a notification if one is found."; + + leaf enabled { + type boolean; + default false; + description + "Enable automatic update checks."; + } + + leaf update-url { + type string; + default "https://github.com/kernelkit/infix"; + description + "Base URL of the update source. The check script appends + /releases/latest and follows the redirect to determine the + latest release tag. Override for customer-specific channels."; + } + } + } + } + + augment "/sys:system-state" { + container software { + description + "Installed software information + + Determined by RAUC, which manages all software upgrades."; + reference "https://rauc.io/"; + + leaf compatible { + type string; + description + "Platform identifier + + Software bundles' compatible attributes are matched against this + one, to determine if they are compatible with one another."; + } + + leaf variant { + type string; + description + "Hardware variant + + Identifies the exact system type."; + } + + leaf booted { + type string; + description + "Slot from which the system was booted."; + } + uses boot-order; + + container update-status { + description + "Result of the last unattended update check."; + + leaf available-version { + type string; + description + "Latest available version, e.g. '26.06.0'. Empty when + no update has been found or the check has not run yet."; + } + + leaf url { + type string; + description + "Direct download URL for the available bundle."; + } + + leaf last-check { + type yang:date-and-time; + description + "Timestamp of the most recent update check."; + } + } + + container installer { + description + "The current state of the software installer service."; + + uses installer-state; + + // TODO: Support sending notifications during bundle installation + // notification state-changed { + // uses installer-state; + // } + } + + list slot { + key "name"; + description + "Details the installed software and current state of a particular + storage slot (partition)."; + + leaf name { + type string; + description + "RAUC's internal name for the slot, in . notation."; + } + + leaf bootname { + type string; + description + "Short name of the slot."; + } + + leaf class { + type string; + description + "Class of software compatible with the slot."; + } + + leaf state { + type string; + description + "The slot's state."; + } + + container bundle { + leaf compatible { + type string; + description + "Platform identifier of the installed software image."; + } + + leaf version { + type string; + description + "Version of the installed software image."; + } + } + + leaf size { + type uint64; + description + "Size, in bytes, of the installed software image."; + } + + leaf sha256 { + type string { + pattern '[a-fA-F0-9]{64}'; + } + description + "Checksum of the installed software image."; + } + container installed { + description + "Logs the time of the last installation and the total number of + updates to this slot."; + + uses rauc-stage-log; + } + + container activated { + description + "Logs the first time the current slot was activated and the total + number of activations."; + uses rauc-stage-log; + } + } + } + } + + rpc install-bundle { + nacm:default-deny-all; + description + "Upgrade the system's software by installing the specified bundle."; + input { + leaf url { + type string; + mandatory true; + description + "The location of the software bundle, specified as a Uniform + Resource Locator (URL). Currently supported protocols include + FTP, HTTP(S) and SCP."; + } + } + } + rpc set-boot-order { + nacm:default-deny-all; + description + "Set order of boot partitions"; + input { + uses boot-order; + must "count(boot-order[.='primary']) <= 1 and + count(boot-order[.='secondary']) <= 1 and + count(boot-order[.='net']) <= 1" { + error-message "Not possible to have duplicate targets in boot order."; + } + } + } +} diff --git a/test/case/system/all.yaml b/test/case/system/all.yaml index b00e9353d..f31dfe46c 100644 --- a/test/case/system/all.yaml +++ b/test/case/system/all.yaml @@ -22,3 +22,6 @@ - name: System Upgrade case: upgrade/test.py + +- name: Schedule Reboot + case: schedule_reboot/test.py diff --git a/test/case/system/schedule_reboot/Readme.adoc b/test/case/system/schedule_reboot/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/system/schedule_reboot/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/system/schedule_reboot/test.adoc b/test/case/system/schedule_reboot/test.adoc new file mode 100644 index 000000000..060be37d1 --- /dev/null +++ b/test/case/system/schedule_reboot/test.adoc @@ -0,0 +1,22 @@ +=== Schedule Reboot + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/system/schedule_reboot] + +==== Description + +Verify that it is possible to schedule a system reboot using the +infix-schedule module. + +==== Topology + +image::topology.svg[Schedule Reboot topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Schedule a reboot +. Verify schedule is configured +. Wait for reboot +. Verify system is back up + + diff --git a/test/case/system/schedule_reboot/test.py b/test/case/system/schedule_reboot/test.py new file mode 100755 index 000000000..45b1d2f59 --- /dev/null +++ b/test/case/system/schedule_reboot/test.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Schedule Reboot + +Verify that it is possible to schedule a system reboot using the +infix-schedule module. +""" +import infamy +from infamy.util import wait_boot + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + target = env.attach("target", "mgmt", "netconf") + + with test.step("Schedule a reboot"): + target.put_config_dicts({ + "ietf-system": { + "system": { + "infix-schedule:schedules": { + "schedule": [ + { + "name": "reboot-test", + "enabled": True, + "predefined-action": "infix-schedule:reboot", + "recurrence": { + "frequency": "ietf-schedule:minutely", + "interval": 1 + } + } + ] + } + } + } + }) + + with test.step("Verify schedule is configured"): + cfg = target.get_config_dict("/ietf-system:system/infix-schedule:schedules/schedule[name='reboot-test']") + assert cfg["system"]["schedules"]["schedule"]["reboot-test"]["name"] == "reboot-test" + + with test.step("Wait for reboot"): + if not wait_boot(target, env): + test.fail("System did not reboot as expected") + + with test.step("Verify system is back up"): + target = env.attach("target", "mgmt", "netconf") + + test.succeed() diff --git a/test/case/system/schedule_reboot/topology.dot b/test/case/system/schedule_reboot/topology.dot new file mode 100644 index 000000000..e6a0d803b --- /dev/null +++ b/test/case/system/schedule_reboot/topology.dot @@ -0,0 +1,23 @@ +graph "1x1" { + layout="neato"; + overlap="false"; + esep="+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt }", + pos="0,12!", + requires="controller", + ]; + + target [ + label="{ mgmt } | target", + pos="10,12!", + + requires="infix", + ]; + + host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"] +} diff --git a/test/case/system/schedule_reboot/topology.svg b/test/case/system/schedule_reboot/topology.svg new file mode 100644 index 000000000..6fc6f47a8 --- /dev/null +++ b/test/case/system/schedule_reboot/topology.svg @@ -0,0 +1,33 @@ + + + + + + +1x1 + + + +host + +host + +mgmt + + + +target + +mgmt + +target + + + +host:mgmt--target:mgmt + + + + From a3dde342294d5bda5ec322a5e9022cc937b961ff Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Tue, 2 Jun 2026 10:07:38 +0200 Subject: [PATCH 2/2] doc: added change log for ietf-schedule Signed-off-by: Ejub Sabic --- doc/ChangeLog.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index fe3d51dc7..719270600 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -3,6 +3,16 @@ Change Log All notable changes to the project are documented in this file. +[v26.06.0][] - [[UNRELEASED]] +------------------------- + +### Changes + +- Added ietf-schedule (basic recurennce) implementation (RFC9922) + +### Fixes + + [v26.05.0][] - 2026-05-29 -------------------------