From 67ea7a74c11a6fc09f722c64c8289ceb6e43dda4 Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Wed, 3 Jun 2026 09:56:52 +0200 Subject: [PATCH 1/3] feat: infix-schedule based on ietf-schedule implementation Signed-off-by: Ejub Sabic --- .../rootfs/etc/profile.d/update-check.sh | 3 + .../rootfs/etc/tmpfiles.d/infix-schedule.conf | 1 + .../common/rootfs/usr/sbin/infix-check-update | 55 ++ doc/ChangeLog.md | 4 + package/confd/confd.mk | 2 +- package/confd/crond.conf | 2 + src/confd/src/Makefile.am | 1 + src/confd/src/core.c | 9 + src/confd/src/core.h | 11 + src/confd/src/schedule.c | 282 ++++++ src/confd/src/system-software.c | 10 + src/confd/src/system.c | 10 + src/confd/yang/confd.inc | 4 +- .../yang/confd/ietf-schedule@2026-03-10.yang | 868 ++++++++++++++++++ src/confd/yang/confd/infix-schedule.yang | 187 ++++ .../yang/confd/infix-schedule@2026-06-17.yang | 1 + .../yang/confd/infix-system-software.yang | 47 + ... => infix-system-software@2026-06-17.yang} | 0 src/confd/yang/confd/infix-system.yang | 25 + ...3-09.yang => infix-system@2026-06-17.yang} | 0 test/case/system/all.yaml | 3 + test/case/system/schedule_reboot/Readme.adoc | 1 + test/case/system/schedule_reboot/test.adoc | 21 + test/case/system/schedule_reboot/test.py | 45 + test/case/system/schedule_reboot/topology.dot | 23 + test/case/system/schedule_reboot/topology.svg | 33 + 26 files changed, 1646 insertions(+), 2 deletions(-) create mode 100644 board/common/rootfs/etc/profile.d/update-check.sh create mode 100644 board/common/rootfs/etc/tmpfiles.d/infix-schedule.conf 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 100644 src/confd/yang/confd/infix-schedule.yang create mode 120000 src/confd/yang/confd/infix-schedule@2026-06-17.yang rename src/confd/yang/confd/{infix-system-software@2024-12-16.yang => infix-system-software@2026-06-17.yang} (100%) rename src/confd/yang/confd/{infix-system@2026-03-09.yang => infix-system@2026-06-17.yang} (100%) 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..f9aa736a5 --- /dev/null +++ b/board/common/rootfs/etc/profile.d/update-check.sh @@ -0,0 +1,3 @@ +if [ -s /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/etc/tmpfiles.d/infix-schedule.conf b/board/common/rootfs/etc/tmpfiles.d/infix-schedule.conf new file mode 100644 index 000000000..f440292d2 --- /dev/null +++ b/board/common/rootfs/etc/tmpfiles.d/infix-schedule.conf @@ -0,0 +1 @@ +f /run/infix-update 0666 admin admin 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..288c35cec --- /dev/null +++ b/board/common/rootfs/usr/sbin/infix-check-update @@ -0,0 +1,55 @@ +#!/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 running config, fall back to upstream +UPDATE_URL=$(copy running-config \ + -x '/ietf-system:system/infix-system:software/check-update/update-url' \ + 2>/dev/null \ + | jq -r '.. | objects | ."update-url"? // empty') +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 \ + | jq -r '.tag_name // empty') +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 + RELEASE_URL="${UPDATE_URL}/releases/${LATEST_TAG}" + MSG="Firmware update available: ${LATEST_TAG}, running ${VERSION} (see ${RELEASE_URL})" + logger -t "$TAG" "$MSG" + printf '%s\n' "$MSG" > "$NOTIFY_FILE" +else + logger -p daemon.debug -t "$TAG" "No update available (current: $VERSION, latest: $LATEST)" + printf '' > "$NOTIFY_FILE" +fi diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index d894b8072..b17a6cd45 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -23,6 +23,10 @@ All notable changes to the project are documented in this file. clients onto the faster 5/6 GHz band - Add `legacy-rates` option to re-enable 802.11b rates on 2.4 GHz for old IoT devices (disabled by default) +- Add system scheduling based on ietf-schedule (RFC 9922), using the + iCalendar recurrence grouping pruned to cron-expressible rules. Schedules + are reusable time-specs; features (`scheduled-reboot`, + `software/check-update`) trigger off them via a schedule reference ### Fixes diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 428e85b98..4ec5b5774 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..55b67ad5c --- /dev/null +++ b/package/confd/crond.conf @@ -0,0 +1,2 @@ +# Cron daemon for infix-schedule +service [2345] crond -f -- Cron daemon diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index fc67edbbb..7e9a8b74f 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -50,6 +50,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 956b5501c..ea6bd8a9d 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -621,6 +621,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))) @@ -794,6 +798,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"); diff --git a/src/confd/src/core.h b/src/confd/src/core.h index b56c8bf32..9d0273e83 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -215,6 +215,17 @@ 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 */ +/* A feature registers cron consumer to run a command on a schedule. */ +struct cron_consumer { + const char *path; /* xpath of the container holding the schedule-ref leaf */ + const char *sched_leaf; /* name of the schedule-ref leaf within 'path' */ + const char *enabled_leaf; /* boolean leaf in 'path' that gates the job; NULL = active whenever a schedule is referenced */ + const char *command; /* what crond runs on each occurrence */ +}; +int schedule_consumer_register(const struct cron_consumer *consumer); +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..1a67d43b3 --- /dev/null +++ b/src/confd/src/schedule.c @@ -0,0 +1,282 @@ +/* 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/admin" + +/* Features register a consumer to run a command on a schedule. */ +static const struct cron_consumer **consumers; +static size_t consumer_count; + +int schedule_consumer_register(const struct cron_consumer *consumer) +{ + const struct cron_consumer **vec; + + if (!consumer || !consumer->path || !consumer->sched_leaf || !consumer->command) + return -1; + + vec = realloc(consumers, (consumer_count + 1) * sizeof(*vec)); + if (!vec) { + ERROR("schedule: out of memory registering %s", consumer->path); + return -1; + } + consumers = vec; + consumers[consumer_count++] = consumer; + return 0; +} + +/* + * Convert ietf-schedule recurrence to a 5-field cron expression. + * + * Frequency mapping: + * minutely/N → *\/N * * * * + * hourly/N → 0 *\/N * * * + * daily/N → 0 0 *\/N * * + * weekly/N → 0 0 * * *\/N + * monthly/N → 0 0 1 *\/N * + * yearly → 0 0 1 1 * (interval > 1 not expressible in cron) + * + * Optional by-* leaves refine the expression: + * byminute → replaces the minute field + * byhour → replaces the hour field + * byday → replaces the day-of-week field + * bymonthday → replaces the day-of-month field (positive values only, 1-31) + * byyearmonth → replaces the month field + */ +static void build_cron_expr(struct lyd_node *recurrence, char *expr, size_t sz) +{ + char min[64], hr[64], dom[64], mon[64], dow[64]; + const char *freq, *ivstr; + struct lyd_node *node; + 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, "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); + } else if (strstr(freq, "yearly")) { + /* Once a year: midnight Jan 1, refined by byyearmonth/bymonthday. + * "every N years" (iv > 1) has no five-field cron equivalent. */ + snprintf(min, sizeof(min), "0"); + snprintf(hr, sizeof(hr), "0"); + snprintf(dom, sizeof(dom), "1"); + snprintf(mon, sizeof(mon), "1"); + } + + /* 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); + } + + /* bymonthday: override day-of-month field */ + first = 1; + LYX_LIST_FOR_EACH(lyd_child(recurrence), node, "bymonthday") { + const char *val = lyd_get_value(node); + if (!val) continue; + if (first) { snprintf(dom, sizeof(dom), "%s", val); first = 0; } + else strncat(dom, ",", sizeof(dom) - strlen(dom) - 1), + strncat(dom, val, sizeof(dom) - strlen(dom) - 1); + } + + /* byyearmonth: override month field */ + first = 1; + LYX_LIST_FOR_EACH(lyd_child(recurrence), node, "byyearmonth") { + const char *val = lyd_get_value(node); + if (!val) continue; + if (first) { snprintf(mon, sizeof(mon), "%s", val); first = 0; } + else strncat(mon, ",", sizeof(mon) - strlen(mon) - 1), + strncat(mon, val, sizeof(mon) - strlen(mon) - 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); +} + +/* + * Resolve a schedule by name to a cron expression. Returns 0 on success. + * A disabled schedule is the master kill-switch: it resolves to "not active" + * (-1) so every feature referencing it stops firing. + */ +static int schedule_to_cron(struct lyd_node *config, const char *name, + char *expr, size_t sz) +{ + struct lyd_node *schedules, *sched; + + if (!config || !name) + return -1; + + schedules = lydx_get_xpathf(config, XPATH_BASE); + if (!schedules) + return -1; + + LYX_LIST_FOR_EACH(lyd_child(schedules), sched, "schedule") { + const char *sname = lydx_get_cattr(sched, "name"); + + if (!sname || strcmp(sname, name)) + continue; + + if (!lydx_is_enabled(sched, "enabled")) + return -1; + + build_cron_expr(lydx_get_child(sched, "recurrence"), expr, sz); + return 0; + } + + return -1; +} + +/* + * Rebuild the crontab from every registered consumer. Each consumer points + * at a feature container holding a schedule-ref; we resolve that to cron + * fields and emit one line running the consumer's own command. + */ +static void apply_schedules(struct lyd_node *config) +{ + int count = 0; + FILE *fp; + size_t i; + + 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"); + + if (!config) + goto out; + + for (i = 0; i < consumer_count; i++) { + const struct cron_consumer *c = consumers[i]; + struct lyd_node *node; + const char *name; + char expr[128]; + + node = lydx_get_xpathf(config, "%s", c->path); + if (!node) + continue; + + if (c->enabled_leaf && !lydx_is_enabled(node, c->enabled_leaf)) + continue; + + name = lydx_get_cattr(node, c->sched_leaf); + if (!name) + continue; + + if (schedule_to_cron(config, name, expr, sizeof(expr))) { + NOTE("schedule: '%s' references inactive schedule '%s', skipping", c->path, name); + continue; + } + + fprintf(fp, "# %s -> %s\n%s %s\n", c->path, name, expr, c->command); + NOTE("schedule: %s → cron '%s %s'", name, expr, c->command); + 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 && event != SR_EV_ENABLED) + return SR_ERR_OK; + + apply_schedules(config); + return SR_ERR_OK; +} diff --git a/src/confd/src/system-software.c b/src/confd/src/system-software.c index 47c735480..7693b6473 100644 --- a/src/confd/src/system-software.c +++ b/src/confd/src/system-software.c @@ -89,6 +89,14 @@ static int infix_system_sw_set_boot_order(sr_session_ctx_t *session, uint32_t su return SR_ERR_OK; } +/* Scheduler consumer for check-update. */ +static const struct cron_consumer check_update_consumer = { + .path = "/ietf-system:system/infix-system:software/check-update", + .sched_leaf = "schedule", + .enabled_leaf = "enabled", + .command = "/usr/sbin/infix-check-update", +}; + int system_sw_rpc_init(struct confd *confd) { int rc = 0; @@ -98,6 +106,8 @@ int system_sw_rpc_init(struct confd *confd) REGISTER_RPC(confd->session, "/infix-system:set-boot-order", infix_system_sw_set_boot_order, NULL, &confd->sub); + schedule_consumer_register(&check_update_consumer); + fail: return rc; } diff --git a/src/confd/src/system.c b/src/confd/src/system.c index 9286afdf0..3890685d2 100644 --- a/src/confd/src/system.c +++ b/src/confd/src/system.c @@ -1678,6 +1678,14 @@ int system_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd return SR_ERR_OK; } +/* Scheduler consumer for scheduled-reboot. */ +static const struct cron_consumer reboot_consumer = { + .path = "/ietf-system:system/infix-system:scheduled-reboot", + .sched_leaf = "schedule", + .enabled_leaf = NULL, + .command = "/usr/sbin/reboot", +}; + int system_rpc_init(struct confd *confd) { int rc; @@ -1688,6 +1696,8 @@ int system_rpc_init(struct confd *confd) REGISTER_RPC(confd->session, "/ietf-system:system-shutdown", rpc_exec, "poweroff", &confd->sub); REGISTER_RPC(confd->session, "/ietf-system:set-current-datetime", rpc_set_datetime, NULL, &confd->sub); + schedule_consumer_register(&reboot_consumer); + return SR_ERR_OK; fail: ERROR("init failed: %s", sr_strerror(rc)); diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 44c29d4fe..e740c6cd4 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -42,8 +42,8 @@ MODULES=( "infix-firewall-services@2025-04-26.yang" "infix-firewall-icmp-types@2025-04-26.yang" "infix-meta@2025-12-10.yang" - "infix-system@2026-03-09.yang" "infix-services@2026-06-17.yang" + "infix-system@2026-06-17.yang" "ieee802-ethernet-interface@2025-09-10.yang" "ieee802-ethernet-phy-type@2025-09-10.yang" "infix-ethernet-interface@2026-05-21.yang" @@ -57,4 +57,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 icalendar-recurrence" + "infix-schedule@2026-06-17.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 100644 index 000000000..0eecfbd13 --- /dev/null +++ b/src/confd/yang/confd/infix-schedule.yang @@ -0,0 +1,187 @@ +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 "KernelKit"; + contact "kernelkit@googlegroups.com"; + description "Infix deviations and augments to ietf-schedule"; + + revision 2026-06-17 { + description + "Initial revision - system scheduling. + + Reusable named schedules built on the + ietf-schedule:icalendar-recurrence grouping (RFC 9922), pruned via + deviations to the subset expressible as five-field cron."; + reference "internal"; + } + + typedef schedule-ref { + type leafref { + path "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:name"; + } + description + "References a named schedule by name. A feature owns its trigger by + pointing a leaf of this type at a schedule; the reference is validated, + so a schedule cannot be deleted while something still uses it, and + several features may share a single recurrence."; + } + + /* + * Prune ietf-schedule:icalendar-recurrence down to the subset that maps + * cleanly onto a five-field cron expression. Everything removed here has + * no cron equivalent; what survives (frequency, interval, byminute, byhour, + * byday/weekday, bymonthday, byyearmonth) is exactly the surface the + * scheduler backend implements. + */ + + /* No start anchor, no UTC/timezone duration -- cron has no notion of one. */ + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:recurrence-first" { + deviate not-supported; + } + + /* No way to bound a recurrence by end-time (until) or occurrence count in + * cron; drop the whole recurrence-end choice and both of its cases. */ + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:recurrence-end" { + deviate not-supported; + } + + /* Explicit period lists are not expressible as cron fields. */ + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:period" { + deviate not-supported; + } + + /* Cron has no seconds field. */ + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:bysecond" { + deviate not-supported; + } + + /* "nth weekday of the month" has no five-field cron equivalent. */ + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:byday" + + "/infix-schedule:direction" { + deviate not-supported; + } + + /* Day-of-year, week-of-year, and set-position have no cron field. */ + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:byyearday" { + deviate not-supported; + } + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:byyearweek" { + deviate not-supported; + } + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:bysetpos" { + deviate not-supported; + } + + /* Cron has no configurable week-start. */ + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:workweek-start" { + deviate not-supported; + } + + /* No exception-date support in cron. */ + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:exception-dates" { + deviate not-supported; + } + + /* Schedules run in the system's local time; no per-schedule timezone. */ + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:time-zone-identifier" { + deviate not-supported; + } + + /* Negative values (last N days) have no five-field cron equivalent. */ + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:bymonthday" { + deviate replace { + type int32 { + range "1..31"; + } + } + } + + augment "/sys:system" { + description + "Scheduling configuration under ietf-system."; + container schedules { + description + "Container for all configured schedules."; + list schedule { + key "name"; + description + "A named, reusable recurrence (time-spec). Schedules carry no + action of their own; features trigger off a schedule by pointing + a schedule-ref leaf at its name."; + leaf name { + type string; + description + "Unique name identifying this schedule."; + } + leaf enabled { + type boolean; + default "true"; + description + "Turn this schedule on or off. When off, anything that uses + it stops running, but the schedule itself is kept."; + } + leaf description { + type string; + description + "Optional human-readable description of this schedule's purpose."; + } + must "recurrence" { + error-message "A recurrence rule is required for each schedule."; + } + container recurrence { + if-feature "schedule:icalendar-recurrence"; + description + "Recurrence rule controlling when the schedule fires. + + Uses the standard iCalendar recurrence grouping; nodes with no + five-field cron equivalent are removed via deviations in this + module."; + uses schedule:icalendar-recurrence { + refine frequency { + mandatory true; + /* secondly has no cron field; a missing frequency would + * otherwise compile to the every-minute expression. */ + must "not(derived-from-or-self(., 'schedule:secondly'))" { + error-message + "secondly frequency is not supported; the finest " + + "supported resolution is minutely."; + } + } + refine interval { + default 1; + } + } + /* cron takes the union of day-of-month and day-of-week, not the + * RFC 5545 intersection, so forbid combining them. */ + must "not(bymonthday and byday)" { + error-message + "Combining bymonthday and byday is not supported: cron would " + + "fire on the union of the two, not their intersection."; + } + } + } + } + } +} diff --git a/src/confd/yang/confd/infix-schedule@2026-06-17.yang b/src/confd/yang/confd/infix-schedule@2026-06-17.yang new file mode 120000 index 000000000..a0e1c997e --- /dev/null +++ b/src/confd/yang/confd/infix-schedule@2026-06-17.yang @@ -0,0 +1 @@ +infix-schedule.yang \ No newline at end of file diff --git a/src/confd/yang/confd/infix-system-software.yang b/src/confd/yang/confd/infix-system-software.yang index 907b4224d..154a80b37 100644 --- a/src/confd/yang/confd/infix-system-software.yang +++ b/src/confd/yang/confd/infix-system-software.yang @@ -16,10 +16,18 @@ submodule infix-system-software { prefix sys; } + import infix-schedule { + prefix infix-schedule; + } + organization "KernelKit"; contact "kernelkit@googlegroups.com"; description "Software status and upgrade."; + revision 2026-06-17 { + description "Add check-update config, triggered from a referenced schedule"; + reference "Internal"; + } revision 2024-12-16 { description "Add boot-order operational data"; reference "Internal"; @@ -80,6 +88,45 @@ 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 'enabled' and 'schedule' references a schedule, the system + checks the configured URL for a newer release on each occurrence + and logs a notification if one is found."; + + leaf enabled { + type boolean; + default false; + description + "Enable automatic update checks."; + } + + leaf schedule { + type infix-schedule:schedule-ref; + description + "The schedule whose occurrences trigger an update check. + Without a referenced schedule no checks are performed."; + } + + 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 diff --git a/src/confd/yang/confd/infix-system-software@2024-12-16.yang b/src/confd/yang/confd/infix-system-software@2026-06-17.yang similarity index 100% rename from src/confd/yang/confd/infix-system-software@2024-12-16.yang rename to src/confd/yang/confd/infix-system-software@2026-06-17.yang diff --git a/src/confd/yang/confd/infix-system.yang b/src/confd/yang/confd/infix-system.yang index 3e60a5b84..b79a2fcad 100644 --- a/src/confd/yang/confd/infix-system.yang +++ b/src/confd/yang/confd/infix-system.yang @@ -22,12 +22,20 @@ module infix-system { "RFC 6991: Common YANG Data Types"; } + import infix-schedule { + prefix infix-schedule; + } + include infix-system-software; organization "KernelKit"; contact "kernelkit@googlegroups.com"; description "Infix augments and deviations to ietf-system."; + revision 2026-06-17 { + description "Add scheduled-reboot, triggered from a referenced schedule."; + reference "internal"; + } revision 2026-03-09 { description "Add stratumweight to NTP client configuration. @@ -318,6 +326,23 @@ module infix-system { } } + augment "/sys:system" { + description "Scheduled reboot of the system."; + + container scheduled-reboot { + description + "Reboot the system on the occurrences of a referenced schedule. + Without a referenced schedule the system is never rebooted on a + schedule."; + + leaf schedule { + type infix-schedule:schedule-ref; + description + "The schedule whose occurrences trigger a system reboot."; + } + } + } + augment "/sys:system/sys:ntp" { leaf stratum-weight { type decimal64 { diff --git a/src/confd/yang/confd/infix-system@2026-03-09.yang b/src/confd/yang/confd/infix-system@2026-06-17.yang similarity index 100% rename from src/confd/yang/confd/infix-system@2026-03-09.yang rename to src/confd/yang/confd/infix-system@2026-06-17.yang 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..3144da3e0 --- /dev/null +++ b/test/case/system/schedule_reboot/test.adoc @@ -0,0 +1,21 @@ +=== 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 +. 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..54e8260f4 --- /dev/null +++ b/test/case/system/schedule_reboot/test.py @@ -0,0 +1,45 @@ +#!/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("Define a schedule and point scheduled-reboot at it"): + target.put_config_dicts({ + "ietf-system": { + "system": { + "infix-schedule:schedules": { + "schedule": [ + { + "name": "reboot-test", + "enabled": True, + "recurrence": { + "frequency": "ietf-schedule:minutely", + "interval": 1 + } + } + ] + }, + "infix-system:scheduled-reboot": { + "schedule": "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 be6d287b1915aa75c248e83984e4253786c49fdd Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Thu, 18 Jun 2026 10:11:01 +0200 Subject: [PATCH 2/3] web: added update notification on web-ui Signed-off-by: Ejub Sabic --- .../common/rootfs/usr/sbin/infix-check-update | 6 +- .../yang/confd/infix-system-software.yang | 2 +- src/webui/internal/handlers/dashboard.go | 99 +++++++++++++------ src/webui/static/css/style.css | 37 +++++++ src/webui/templates/pages/dashboard.html | 15 +++ test/case/system/schedule_reboot/test.py | 2 +- 6 files changed, 126 insertions(+), 35 deletions(-) diff --git a/board/common/rootfs/usr/sbin/infix-check-update b/board/common/rootfs/usr/sbin/infix-check-update index 288c35cec..7c6a7fad0 100755 --- a/board/common/rootfs/usr/sbin/infix-check-update +++ b/board/common/rootfs/usr/sbin/infix-check-update @@ -1,6 +1,6 @@ #!/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. +# Check for available software updates and notify on login if one exists. +# Called by the scheduler. NOTIFY_FILE=/run/infix-update TAG=infix-update @@ -46,7 +46,7 @@ newer() { if [ "$IS_RELEASE" = false ] || newer "$LATEST" "$VERSION"; then RELEASE_URL="${UPDATE_URL}/releases/${LATEST_TAG}" - MSG="Firmware update available: ${LATEST_TAG}, running ${VERSION} (see ${RELEASE_URL})" + MSG="Software update available: ${LATEST_TAG}, running ${VERSION} (see ${RELEASE_URL})" logger -t "$TAG" "$MSG" printf '%s\n' "$MSG" > "$NOTIFY_FILE" else diff --git a/src/confd/yang/confd/infix-system-software.yang b/src/confd/yang/confd/infix-system-software.yang index 154a80b37..29afb29f7 100644 --- a/src/confd/yang/confd/infix-system-software.yang +++ b/src/confd/yang/confd/infix-system-software.yang @@ -95,7 +95,7 @@ submodule infix-system-software { container check-update { description - "Policy for automatic firmware update checks. + "Policy for automatic software update checks. When 'enabled' and 'schedule' references a schedule, the system checks the configured URL for a newer release on each occurrence diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index 5c1c0e08c..53953e914 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -11,7 +11,9 @@ import ( "math" "net" "net/http" + "os" "os/exec" + "regexp" "strconv" "strings" "sync" @@ -199,15 +201,15 @@ type hardwareWrapper struct { } type hwComponentJSON struct { - Name string `json:"name"` - Class string `json:"class"` - Description string `json:"description"` - Parent string `json:"parent"` - MfgName string `json:"mfg-name"` - ModelName string `json:"model-name"` - SerialNum string `json:"serial-num"` - HardwareRev string `json:"hardware-rev"` - PhysAddress string `json:"infix-hardware:phys-address"` + Name string `json:"name"` + Class string `json:"class"` + Description string `json:"description"` + Parent string `json:"parent"` + MfgName string `json:"mfg-name"` + ModelName string `json:"model-name"` + SerialNum string `json:"serial-num"` + HardwareRev string `json:"hardware-rev"` + PhysAddress string `json:"infix-hardware:phys-address"` WiFiRadio *wifiRadioHWJSON `json:"infix-hardware:wifi-radio"` GPSReceiver *struct{} `json:"infix-hardware:gps-receiver"` SensorData *struct { @@ -226,26 +228,26 @@ type hwComponentJSON struct { type dashboardData struct { PageData - Hostname string - Contact string - Location string - OSName string - OSVersion string - Machine string - CurrentTime string - Software string - Uptime string - MemTotal int64 - MemUsed int64 - MemPercent int - MemClass string - Load1 string - Load5 string - Load15 string - CPUClass string - Disks []diskEntry - Board boardInfo - KeyVitals []sensorEntry // Overview's at-a-glance subset: CPU/SoC + wifi-radio temperatures and fan RPMs. Status > Hardware has the full inventory. + Hostname string + Contact string + Location string + OSName string + OSVersion string + Machine string + CurrentTime string + Software string + Uptime string + MemTotal int64 + MemUsed int64 + MemPercent int + MemClass string + Load1 string + Load5 string + Load15 string + CPUClass string + Disks []diskEntry + Board boardInfo + KeyVitals []sensorEntry // Overview's at-a-glance subset: CPU/SoC + wifi-radio temperatures and fan RPMs. Status > Hardware has the full inventory. // Connectivity card. Gateways []gatewayEntry InternetProbe string // address pinged for the Internet reachability row @@ -254,7 +256,11 @@ type dashboardData struct { NTPSync string // "" / the selected NTP source address // Addresses card. Addresses []ifaceAddrEntry - Error string + // Software-update banner — shown only when an update is available. + UpdateAvailable bool + UpdateMessage string // verbatim CLI/login-banner notice + UpdateURL string // release URL extracted from the notice + Error string } // gatewayEntry is a default route's next-hop. @@ -305,6 +311,31 @@ type diskEntry struct { // reachability row — a well-known, stable anycast resolver. const internetProbe = "1.1.1.1" +// updateNoticeFile holds the software-update notice written by +// /usr/sbin/infix-check-update — the same text the CLI shows at login. +// A package var so tests can point it elsewhere. +var updateNoticeFile = "/run/infix-update" + +var updateURLRe = regexp.MustCompile(`https?://\S+`) + +// readUpdateNotice returns the software-update message verbatim (as shown in +// the CLI login banner) and the release URL extracted from it. Both are empty +// when no update is pending or the notice file is absent/empty. +func readUpdateNotice() (msg, url string) { + b, err := os.ReadFile(updateNoticeFile) + if err != nil { + return "", "" + } + msg = strings.TrimSpace(string(b)) + if msg == "" { + return "", "" + } + if u := updateURLRe.FindString(msg); u != "" { + url = strings.TrimRight(u, ").,;") + } + return msg, url +} + // DashboardHandler serves the main dashboard page. type DashboardHandler struct { Template *template.Template @@ -501,6 +532,14 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { } data.Addresses = ifaceAddresses(ifaces) + // Software-update banner: surfaces the same notice as the CLI login + // banner (written by infix-check-update to the notice file). + if msg, url := readUpdateNotice(); msg != "" { + data.UpdateAvailable = true + data.UpdateMessage = msg + data.UpdateURL = url + } + tmplName := "dashboard.html" if r.Header.Get("HX-Request") == "true" { tmplName = "content" diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 3c060ea0f..aafcf2c48 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -579,6 +579,43 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { padding: 1.25rem; } +/* Dashboard software-update banner — amber "attention" treatment, bright + and theme-aware, but built from the same card primitives as everything + else. Spans the full info-grid width and sits at the top. */ +.update-card { + border-color: var(--warning-border); + background: var(--warning-bg); + box-shadow: 0 0 0 1px var(--warning-border), var(--shadow-sm); +} +.update-card .update-card-header { + background: var(--warning); + color: #fff; + border-bottom-color: var(--warning-border); + display: flex; + align-items: center; + gap: 0.5rem; +} +.update-card .update-card-icon { + font-size: 1rem; + line-height: 1; +} +.update-card .update-card-body { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem 1.5rem; + flex-wrap: wrap; +} +.update-card .update-card-msg { + margin: 0; + color: var(--warning-fg); + font-size: 0.95rem; + font-weight: 500; +} +.update-card .update-card-action { + flex: none; +} + /* ========================================================================== Tables diff --git a/src/webui/templates/pages/dashboard.html b/src/webui/templates/pages/dashboard.html index 7f70c8b70..dafd87d88 100644 --- a/src/webui/templates/pages/dashboard.html +++ b/src/webui/templates/pages/dashboard.html @@ -8,6 +8,21 @@ {{end}}
+ {{if .UpdateAvailable}} +
+
+ Software Update Available +
+
+

{{.UpdateMessage}}

+ {{if .UpdateURL}} + View release → + {{end}} +
+
+ {{end}} +
System Information
diff --git a/test/case/system/schedule_reboot/test.py b/test/case/system/schedule_reboot/test.py index 54e8260f4..c04d1413f 100755 --- a/test/case/system/schedule_reboot/test.py +++ b/test/case/system/schedule_reboot/test.py @@ -12,7 +12,7 @@ env = infamy.Env() target = env.attach("target", "mgmt", "netconf") - with test.step("Define a schedule and point scheduled-reboot at it"): + with test.step("Schedule a reboot"): target.put_config_dicts({ "ietf-system": { "system": { From 797c5c09f1976e10666adbafcb83643d824269ce Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Thu, 18 Jun 2026 13:12:18 +0200 Subject: [PATCH 3/3] fix: remove infix from commands and state files Signed-off-by: Ejub Sabic --- board/common/rootfs/etc/profile.d/update-check.sh | 4 ++-- board/common/rootfs/etc/tmpfiles.d/infix-schedule.conf | 1 - board/common/rootfs/etc/tmpfiles.d/os-schedule.conf | 1 + .../rootfs/usr/sbin/{infix-check-update => check-update} | 4 ++-- src/confd/src/system-software.c | 2 +- src/webui/internal/handlers/dashboard.go | 6 +++--- 6 files changed, 9 insertions(+), 9 deletions(-) delete mode 100644 board/common/rootfs/etc/tmpfiles.d/infix-schedule.conf create mode 100644 board/common/rootfs/etc/tmpfiles.d/os-schedule.conf rename board/common/rootfs/usr/sbin/{infix-check-update => check-update} (97%) diff --git a/board/common/rootfs/etc/profile.d/update-check.sh b/board/common/rootfs/etc/profile.d/update-check.sh index f9aa736a5..8cd7424d7 100644 --- a/board/common/rootfs/etc/profile.d/update-check.sh +++ b/board/common/rootfs/etc/profile.d/update-check.sh @@ -1,3 +1,3 @@ -if [ -s /run/infix-update ]; then - printf '\n\033[1;33m *** %s ***\033[0m\n\n' "$(cat /run/infix-update)" +if [ -s /run/os-update ]; then + printf '\n\033[1;33m *** %s ***\033[0m\n\n' "$(cat /run/os-update)" fi diff --git a/board/common/rootfs/etc/tmpfiles.d/infix-schedule.conf b/board/common/rootfs/etc/tmpfiles.d/infix-schedule.conf deleted file mode 100644 index f440292d2..000000000 --- a/board/common/rootfs/etc/tmpfiles.d/infix-schedule.conf +++ /dev/null @@ -1 +0,0 @@ -f /run/infix-update 0666 admin admin diff --git a/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf b/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf new file mode 100644 index 000000000..56e49080a --- /dev/null +++ b/board/common/rootfs/etc/tmpfiles.d/os-schedule.conf @@ -0,0 +1 @@ +f /run/os-update 0666 admin admin diff --git a/board/common/rootfs/usr/sbin/infix-check-update b/board/common/rootfs/usr/sbin/check-update similarity index 97% rename from board/common/rootfs/usr/sbin/infix-check-update rename to board/common/rootfs/usr/sbin/check-update index 7c6a7fad0..f380bf392 100755 --- a/board/common/rootfs/usr/sbin/infix-check-update +++ b/board/common/rootfs/usr/sbin/check-update @@ -2,8 +2,8 @@ # Check for available software updates and notify on login if one exists. # Called by the scheduler. -NOTIFY_FILE=/run/infix-update -TAG=infix-update +NOTIFY_FILE=/run/os-update +TAG=os-update # Source os-release for VERSION and IMAGE_ID if [ ! -f /etc/os-release ]; then diff --git a/src/confd/src/system-software.c b/src/confd/src/system-software.c index 7693b6473..50a265491 100644 --- a/src/confd/src/system-software.c +++ b/src/confd/src/system-software.c @@ -94,7 +94,7 @@ static const struct cron_consumer check_update_consumer = { .path = "/ietf-system:system/infix-system:software/check-update", .sched_leaf = "schedule", .enabled_leaf = "enabled", - .command = "/usr/sbin/infix-check-update", + .command = "/usr/sbin/check-update", }; int system_sw_rpc_init(struct confd *confd) diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index 53953e914..e51dfc11b 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -312,9 +312,9 @@ type diskEntry struct { const internetProbe = "1.1.1.1" // updateNoticeFile holds the software-update notice written by -// /usr/sbin/infix-check-update — the same text the CLI shows at login. +// /usr/sbin/check-update — the same text the CLI shows at login. // A package var so tests can point it elsewhere. -var updateNoticeFile = "/run/infix-update" +var updateNoticeFile = "/run/os-update" var updateURLRe = regexp.MustCompile(`https?://\S+`) @@ -533,7 +533,7 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { data.Addresses = ifaceAddresses(ifaces) // Software-update banner: surfaces the same notice as the CLI login - // banner (written by infix-check-update to the notice file). + // banner (written by check-update to the notice file). if msg, url := readUpdateNotice(); msg != "" { data.UpdateAvailable = true data.UpdateMessage = msg