From e693075fb103ac25b870ed1dea7a08c645d2f750 Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Wed, 3 Jun 2026 09:56:52 +0200 Subject: [PATCH] feat: infix-schedule based on ietf-schedule(basic) 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 | 56 ++ doc/ChangeLog.md | 10 + 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 | 242 +++++ src/confd/yang/confd.inc | 2 + .../yang/confd/ietf-schedule@2026-03-10.yang | 868 ++++++++++++++++++ src/confd/yang/confd/infix-schedule.yang | 217 +++++ .../yang/confd/infix-schedule@2026-06-02.yang | 1 + .../yang/confd/infix-system-software.yang | 36 + .../infix-system-software@2026-05-29.yang | 1 + 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 | 43 + test/case/system/schedule_reboot/topology.dot | 23 + test/case/system/schedule_reboot/topology.svg | 33 + 22 files changed, 1583 insertions(+), 1 deletion(-) 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-02.yang create mode 120000 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..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..9406d201c --- /dev/null +++ b/board/common/rootfs/usr/sbin/infix-check-update @@ -0,0 +1,56 @@ +#!/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 \ + | 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)" + printf '' > "$NOTIFY_FILE" +fi 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 ------------------------- 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..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 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 956b5501c..833710eb4 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"); @@ -858,6 +867,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..ced04cdf7 --- /dev/null +++ b/src/confd/src/schedule.c @@ -0,0 +1,242 @@ +/* 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" + +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: + * 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 + * 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) +{ + 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, "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); + } + + /* 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); +} + +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 850829997..08850e085 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -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 basic-recurrence" + "infix-schedule@2026-06-02.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..443be4849 --- /dev/null +++ b/src/confd/yang/confd/infix-schedule.yang @@ -0,0 +1,217 @@ +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-06-02 { + description + "Restrict frequency to cron-expressible values via deviate replace. + secondly and yearly cannot be represented in standard five-field + cron syntax and are therefore excluded."; + } + + 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."; + } + + identity supported-frequency { + description + "Base identity for frequencies expressible in five-field cron syntax. + Excludes secondly and yearly which have no cron equivalent."; + } + + identity minutely { + base supported-frequency; + base schedule:minutely; + description + "Repeat every N minutes."; + } + + identity hourly { + base supported-frequency; + base schedule:hourly; + description + "Repeat every N hours."; + } + + identity daily { + base supported-frequency; + base schedule:daily; + description + "Repeat every N days."; + } + + identity weekly { + base supported-frequency; + base schedule:weekly; + description + "Repeat every N weeks."; + } + + identity monthly { + base supported-frequency; + base schedule:monthly; + description + "Repeat every N months."; + } + + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:frequency" { + deviate replace { + type identityref { + base supported-frequency; + } + } + } + + deviation "/sys:system/infix-schedule:schedules/infix-schedule:schedule" + + "/infix-schedule:recurrence/infix-schedule:bymonthday" { + // Negative values (last N days) have no five-field cron equivalent. + 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 schedule that executes a predefined action + according to a recurrence rule."; + leaf name { + type string; + description + "Unique name identifying this schedule."; + } + leaf enabled { + type boolean; + default "true"; + description + "Enable or disable this schedule without removing it."; + } + leaf description { + type string; + description + "Optional human-readable description of this schedule's purpose."; + } + choice action { + description + "The action to perform when the schedule fires."; + case predefined { + description + "Execute a built-in system action."; + leaf predefined-action { + type identityref { + base predefined-action; + } + description + "The predefined action to execute, e.g. reboot or check-update."; + } + } + } + container recurrence { + if-feature "schedule:basic-recurrence"; + description + "Recurrence rule controlling when the schedule fires."; + uses schedule:recurrence-basic; + leaf-list byhour { + type uint32 { + range "0..23"; + } + description + "Restrict firing to these hours of the day (0-23). + Overrides the hour field derived from the frequency."; + } + leaf-list byminute { + type uint32 { + range "0..59"; + } + description + "Restrict firing to these minutes within the hour (0-59). + Overrides the minute field derived from the frequency."; + } + list byday { + key "weekday"; + description + "Restrict firing to specific days of the week. + Overrides the day-of-week field derived from the frequency."; + leaf weekday { + type schedule:weekday; + description + "Day of the week on which the schedule may fire."; + } + } + leaf-list bymonthday { + type int32 { + range "-31..-1|1..31"; + } + description + "Restrict firing to specific days of the month. + Positive values count from the start of the month; + negative values count from the end (e.g. -1 is the last day)."; + } + leaf-list byyearmonth { + type uint32 { + range "1..12"; + } + description + "Restrict firing to specific months of the year (1-12)."; + } + } + container status { + config false; + description + "Operational status for this schedule."; + uses schedule:schedule-status; + leaf cron-expression { + type string; + description + "The five-field cron expression generated from the recurrence rule."; + } + } + } + } + } +} diff --git a/src/confd/yang/confd/infix-schedule@2026-06-02.yang b/src/confd/yang/confd/infix-schedule@2026-06-02.yang new file mode 120000 index 000000000..a0e1c997e --- /dev/null +++ b/src/confd/yang/confd/infix-schedule@2026-06-02.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..3e663c5ce 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"; + 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 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 120000 index 000000000..e01b714cb --- /dev/null +++ b/src/confd/yang/confd/infix-system-software@2026-05-29.yang @@ -0,0 +1 @@ +infix-system-software.yang \ No newline at end of file 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..298bd0614 --- /dev/null +++ b/test/case/system/schedule_reboot/test.py @@ -0,0 +1,43 @@ +#!/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": "infix-schedule:minutely", + "interval": 1 + } + } + ] + } + } + } + }) + + 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 + + + +