mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-30 04:33:00 +02:00
Merge pull request #1523 from saba8814/feat/add-scheduler-support
Add support for ietf-schedule
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
if [ -s /run/os-update ]; then
|
||||||
|
printf '\n\033[1;33m *** %s ***\033[0m\n\n' "$(cat /run/os-update)"
|
||||||
|
fi
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
f /run/os-update 0666 admin admin
|
||||||
Executable
+55
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Check for available software updates and notify on login if one exists.
|
||||||
|
# Called by the scheduler.
|
||||||
|
|
||||||
|
NOTIFY_FILE=/run/os-update
|
||||||
|
TAG=os-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="Software 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
|
||||||
@@ -23,6 +23,10 @@ All notable changes to the project are documented in this file.
|
|||||||
clients onto the faster 5/6 GHz band
|
clients onto the faster 5/6 GHz band
|
||||||
- Add `legacy-rates` option to re-enable 802.11b rates on 2.4 GHz for
|
- Add `legacy-rates` option to re-enable 802.11b rates on 2.4 GHz for
|
||||||
old IoT devices (disabled by default)
|
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
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ else
|
|||||||
CONFD_CONF_OPTS += --disable-gps
|
CONFD_CONF_OPTS += --disable-gps
|
||||||
endif
|
endif
|
||||||
define CONFD_INSTALL_EXTRA
|
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/; \
|
cp $(CONFD_PKGDIR)/$$fn $(FINIT_D)/available/; \
|
||||||
ln -sf ../available/$$fn $(FINIT_D)/enabled/$$fn; \
|
ln -sf ../available/$$fn $(FINIT_D)/enabled/$$fn; \
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Cron daemon for infix-schedule
|
||||||
|
service [2345] crond -f -- Cron daemon
|
||||||
@@ -50,6 +50,7 @@ confd_plugin_la_SOURCES = \
|
|||||||
if-wireguard.c \
|
if-wireguard.c \
|
||||||
keystore.c \
|
keystore.c \
|
||||||
system.c \
|
system.c \
|
||||||
|
schedule.c \
|
||||||
ntp.c \
|
ntp.c \
|
||||||
ptp.c \
|
ptp.c \
|
||||||
syslog.c \
|
syslog.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)))
|
if ((rc = system_change(session, config, diff, event, confd)))
|
||||||
goto free_diff;
|
goto free_diff;
|
||||||
|
|
||||||
|
/* infix-schedule */
|
||||||
|
if ((rc = schedule_change(session, config, diff, event, confd)))
|
||||||
|
goto free_diff;
|
||||||
|
|
||||||
/* infix-containers */
|
/* infix-containers */
|
||||||
#ifdef CONTAINERS
|
#ifdef CONTAINERS
|
||||||
if ((rc = containers_change(session, config, diff, event, confd)))
|
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");
|
ERROR("Failed to subscribe to ietf-hardware");
|
||||||
goto err;
|
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);
|
rc = subscribe_model("infix-firewall", &confd, 0);
|
||||||
if (rc) {
|
if (rc) {
|
||||||
ERROR("Failed to subscribe to infix-firewall");
|
ERROR("Failed to subscribe to infix-firewall");
|
||||||
|
|||||||
@@ -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 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);
|
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 */
|
/* containers.c */
|
||||||
#ifdef CONTAINERS
|
#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);
|
int containers_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd);
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/* SPDX-License-Identifier: BSD-3-Clause */
|
||||||
|
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include <libite/lite.h>
|
||||||
|
#include <srx/common.h>
|
||||||
|
#include <srx/lyx.h>
|
||||||
|
#include <srx/srx_val.h>
|
||||||
|
#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;
|
||||||
|
}
|
||||||
@@ -89,6 +89,14 @@ static int infix_system_sw_set_boot_order(sr_session_ctx_t *session, uint32_t su
|
|||||||
return SR_ERR_OK;
|
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/check-update",
|
||||||
|
};
|
||||||
|
|
||||||
int system_sw_rpc_init(struct confd *confd)
|
int system_sw_rpc_init(struct confd *confd)
|
||||||
{
|
{
|
||||||
int rc = 0;
|
int rc = 0;
|
||||||
@@ -98,6 +106,8 @@ int system_sw_rpc_init(struct confd *confd)
|
|||||||
REGISTER_RPC(confd->session, "/infix-system:set-boot-order",
|
REGISTER_RPC(confd->session, "/infix-system:set-boot-order",
|
||||||
infix_system_sw_set_boot_order, NULL, &confd->sub);
|
infix_system_sw_set_boot_order, NULL, &confd->sub);
|
||||||
|
|
||||||
|
schedule_consumer_register(&check_update_consumer);
|
||||||
|
|
||||||
fail:
|
fail:
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1678,6 +1678,14 @@ int system_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd
|
|||||||
return SR_ERR_OK;
|
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 system_rpc_init(struct confd *confd)
|
||||||
{
|
{
|
||||||
int rc;
|
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:system-shutdown", rpc_exec, "poweroff", &confd->sub);
|
||||||
REGISTER_RPC(confd->session, "/ietf-system:set-current-datetime", rpc_set_datetime, NULL, &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;
|
return SR_ERR_OK;
|
||||||
fail:
|
fail:
|
||||||
ERROR("init failed: %s", sr_strerror(rc));
|
ERROR("init failed: %s", sr_strerror(rc));
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ MODULES=(
|
|||||||
"infix-firewall-services@2025-04-26.yang"
|
"infix-firewall-services@2025-04-26.yang"
|
||||||
"infix-firewall-icmp-types@2025-04-26.yang"
|
"infix-firewall-icmp-types@2025-04-26.yang"
|
||||||
"infix-meta@2025-12-10.yang"
|
"infix-meta@2025-12-10.yang"
|
||||||
"infix-system@2026-03-09.yang"
|
|
||||||
"infix-services@2026-06-17.yang"
|
"infix-services@2026-06-17.yang"
|
||||||
|
"infix-system@2026-06-17.yang"
|
||||||
"ieee802-ethernet-interface@2025-09-10.yang"
|
"ieee802-ethernet-interface@2025-09-10.yang"
|
||||||
"ieee802-ethernet-phy-type@2025-09-10.yang"
|
"ieee802-ethernet-phy-type@2025-09-10.yang"
|
||||||
"infix-ethernet-interface@2026-05-21.yang"
|
"infix-ethernet-interface@2026-05-21.yang"
|
||||||
@@ -57,4 +57,6 @@ MODULES=(
|
|||||||
"ieee1588-ptp-tt@2023-08-14.yang -e timestamp-correction"
|
"ieee1588-ptp-tt@2023-08-14.yang -e timestamp-correction"
|
||||||
"ieee802-dot1as-gptp@2025-12-10.yang"
|
"ieee802-dot1as-gptp@2025-12-10.yang"
|
||||||
"infix-ptp@2026-04-07.yang"
|
"infix-ptp@2026-04-07.yang"
|
||||||
|
"ietf-schedule@2026-03-10.yang -e icalendar-recurrence"
|
||||||
|
"infix-schedule@2026-06-17.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: <https://datatracker.ietf.org/wg/netmod/>
|
||||||
|
WG List: <mailto:netmod@ietf.org>
|
||||||
|
|
||||||
|
Editor: Qiufang Ma
|
||||||
|
<mailto:maqiufang1@huawei.com>
|
||||||
|
Author: Qin Wu
|
||||||
|
<mailto:bill.wu@huawei.com>
|
||||||
|
Editor: Mohamed Boucadair
|
||||||
|
<mailto:mohamed.boucadair@orange.com>
|
||||||
|
Author: Daniel King
|
||||||
|
<mailto:d.king@lancaster.ac.uk>";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
infix-schedule.yang
|
||||||
@@ -16,10 +16,18 @@ submodule infix-system-software {
|
|||||||
prefix sys;
|
prefix sys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import infix-schedule {
|
||||||
|
prefix infix-schedule;
|
||||||
|
}
|
||||||
|
|
||||||
organization "KernelKit";
|
organization "KernelKit";
|
||||||
contact "kernelkit@googlegroups.com";
|
contact "kernelkit@googlegroups.com";
|
||||||
description "Software status and upgrade.";
|
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 {
|
revision 2024-12-16 {
|
||||||
description "Add boot-order operational data";
|
description "Add boot-order operational data";
|
||||||
reference "Internal";
|
reference "Internal";
|
||||||
@@ -80,6 +88,45 @@ submodule infix-system-software {
|
|||||||
"The last error encountered by the installer service.";
|
"The last error encountered by the installer service.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
augment "/sys:system" {
|
||||||
|
container software {
|
||||||
|
description
|
||||||
|
"Software management configuration.";
|
||||||
|
|
||||||
|
container check-update {
|
||||||
|
description
|
||||||
|
"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
|
||||||
|
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" {
|
augment "/sys:system-state" {
|
||||||
container software {
|
container software {
|
||||||
description
|
description
|
||||||
|
|||||||
@@ -22,12 +22,20 @@ module infix-system {
|
|||||||
"RFC 6991: Common YANG Data Types";
|
"RFC 6991: Common YANG Data Types";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import infix-schedule {
|
||||||
|
prefix infix-schedule;
|
||||||
|
}
|
||||||
|
|
||||||
include infix-system-software;
|
include infix-system-software;
|
||||||
|
|
||||||
organization "KernelKit";
|
organization "KernelKit";
|
||||||
contact "kernelkit@googlegroups.com";
|
contact "kernelkit@googlegroups.com";
|
||||||
description "Infix augments and deviations to ietf-system.";
|
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 {
|
revision 2026-03-09 {
|
||||||
description "Add stratumweight to NTP client configuration.
|
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" {
|
augment "/sys:system/sys:ntp" {
|
||||||
leaf stratum-weight {
|
leaf stratum-weight {
|
||||||
type decimal64 {
|
type decimal64 {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -199,15 +201,15 @@ type hardwareWrapper struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type hwComponentJSON struct {
|
type hwComponentJSON struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Class string `json:"class"`
|
Class string `json:"class"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Parent string `json:"parent"`
|
Parent string `json:"parent"`
|
||||||
MfgName string `json:"mfg-name"`
|
MfgName string `json:"mfg-name"`
|
||||||
ModelName string `json:"model-name"`
|
ModelName string `json:"model-name"`
|
||||||
SerialNum string `json:"serial-num"`
|
SerialNum string `json:"serial-num"`
|
||||||
HardwareRev string `json:"hardware-rev"`
|
HardwareRev string `json:"hardware-rev"`
|
||||||
PhysAddress string `json:"infix-hardware:phys-address"`
|
PhysAddress string `json:"infix-hardware:phys-address"`
|
||||||
WiFiRadio *wifiRadioHWJSON `json:"infix-hardware:wifi-radio"`
|
WiFiRadio *wifiRadioHWJSON `json:"infix-hardware:wifi-radio"`
|
||||||
GPSReceiver *struct{} `json:"infix-hardware:gps-receiver"`
|
GPSReceiver *struct{} `json:"infix-hardware:gps-receiver"`
|
||||||
SensorData *struct {
|
SensorData *struct {
|
||||||
@@ -226,26 +228,26 @@ type hwComponentJSON struct {
|
|||||||
|
|
||||||
type dashboardData struct {
|
type dashboardData struct {
|
||||||
PageData
|
PageData
|
||||||
Hostname string
|
Hostname string
|
||||||
Contact string
|
Contact string
|
||||||
Location string
|
Location string
|
||||||
OSName string
|
OSName string
|
||||||
OSVersion string
|
OSVersion string
|
||||||
Machine string
|
Machine string
|
||||||
CurrentTime string
|
CurrentTime string
|
||||||
Software string
|
Software string
|
||||||
Uptime string
|
Uptime string
|
||||||
MemTotal int64
|
MemTotal int64
|
||||||
MemUsed int64
|
MemUsed int64
|
||||||
MemPercent int
|
MemPercent int
|
||||||
MemClass string
|
MemClass string
|
||||||
Load1 string
|
Load1 string
|
||||||
Load5 string
|
Load5 string
|
||||||
Load15 string
|
Load15 string
|
||||||
CPUClass string
|
CPUClass string
|
||||||
Disks []diskEntry
|
Disks []diskEntry
|
||||||
Board boardInfo
|
Board boardInfo
|
||||||
KeyVitals []sensorEntry // Overview's at-a-glance subset: CPU/SoC + wifi-radio temperatures and fan RPMs. Status > Hardware has the full inventory.
|
KeyVitals []sensorEntry // Overview's at-a-glance subset: CPU/SoC + wifi-radio temperatures and fan RPMs. Status > Hardware has the full inventory.
|
||||||
// Connectivity card.
|
// Connectivity card.
|
||||||
Gateways []gatewayEntry
|
Gateways []gatewayEntry
|
||||||
InternetProbe string // address pinged for the Internet reachability row
|
InternetProbe string // address pinged for the Internet reachability row
|
||||||
@@ -254,7 +256,11 @@ type dashboardData struct {
|
|||||||
NTPSync string // "" / the selected NTP source address
|
NTPSync string // "" / the selected NTP source address
|
||||||
// Addresses card.
|
// Addresses card.
|
||||||
Addresses []ifaceAddrEntry
|
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.
|
// gatewayEntry is a default route's next-hop.
|
||||||
@@ -305,6 +311,31 @@ type diskEntry struct {
|
|||||||
// reachability row — a well-known, stable anycast resolver.
|
// reachability row — a well-known, stable anycast resolver.
|
||||||
const internetProbe = "1.1.1.1"
|
const internetProbe = "1.1.1.1"
|
||||||
|
|
||||||
|
// updateNoticeFile holds the software-update notice written by
|
||||||
|
// /usr/sbin/check-update — the same text the CLI shows at login.
|
||||||
|
// A package var so tests can point it elsewhere.
|
||||||
|
var updateNoticeFile = "/run/os-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.
|
// DashboardHandler serves the main dashboard page.
|
||||||
type DashboardHandler struct {
|
type DashboardHandler struct {
|
||||||
Template *template.Template
|
Template *template.Template
|
||||||
@@ -501,6 +532,14 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
data.Addresses = ifaceAddresses(ifaces)
|
data.Addresses = ifaceAddresses(ifaces)
|
||||||
|
|
||||||
|
// Software-update banner: surfaces the same notice as the CLI login
|
||||||
|
// banner (written by check-update to the notice file).
|
||||||
|
if msg, url := readUpdateNotice(); msg != "" {
|
||||||
|
data.UpdateAvailable = true
|
||||||
|
data.UpdateMessage = msg
|
||||||
|
data.UpdateURL = url
|
||||||
|
}
|
||||||
|
|
||||||
tmplName := "dashboard.html"
|
tmplName := "dashboard.html"
|
||||||
if r.Header.Get("HX-Request") == "true" {
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
tmplName = "content"
|
tmplName = "content"
|
||||||
|
|||||||
@@ -579,6 +579,43 @@ details[open].nav-group-top > summary.nav-group-summary-top::before {
|
|||||||
padding: 1.25rem;
|
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
|
Tables
|
||||||
|
|||||||
@@ -8,6 +8,21 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<section class="info-grid">
|
<section class="info-grid">
|
||||||
|
{{if .UpdateAvailable}}
|
||||||
|
<div class="info-card info-grid-full update-card">
|
||||||
|
<div class="card-header update-card-header">
|
||||||
|
<span class="update-card-icon" aria-hidden="true">⬆</span> Software Update Available
|
||||||
|
</div>
|
||||||
|
<div class="card-body update-card-body">
|
||||||
|
<p class="update-card-msg">{{.UpdateMessage}}</p>
|
||||||
|
{{if .UpdateURL}}
|
||||||
|
<a href="{{.UpdateURL}}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="btn btn-primary update-card-action">View release →</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="card-header">System Information</div>
|
<div class="card-header">System Information</div>
|
||||||
<div class="card-body" style="padding:0">
|
<div class="card-body" style="padding:0">
|
||||||
|
|||||||
@@ -22,3 +22,6 @@
|
|||||||
|
|
||||||
- name: System Upgrade
|
- name: System Upgrade
|
||||||
case: upgrade/test.py
|
case: upgrade/test.py
|
||||||
|
|
||||||
|
- name: Schedule Reboot
|
||||||
|
case: schedule_reboot/test.py
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
Executable
+45
@@ -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("Schedule a reboot"):
|
||||||
|
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()
|
||||||
@@ -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> mgmt }",
|
||||||
|
pos="0,12!",
|
||||||
|
requires="controller",
|
||||||
|
];
|
||||||
|
|
||||||
|
target [
|
||||||
|
label="{ <mgmt> mgmt } | target",
|
||||||
|
pos="10,12!",
|
||||||
|
|
||||||
|
requires="infix",
|
||||||
|
];
|
||||||
|
|
||||||
|
host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||||
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
|
||||||
|
<!-- Title: 1x1 Pages: 1 -->
|
||||||
|
<svg width="424pt" height="45pt"
|
||||||
|
viewBox="0.00 0.00 424.03 45.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 41)">
|
||||||
|
<title>1x1</title>
|
||||||
|
<polygon fill="white" stroke="transparent" points="-4,4 -4,-41 420.03,-41 420.03,4 -4,4"/>
|
||||||
|
<!-- host -->
|
||||||
|
<g id="node1" class="node">
|
||||||
|
<title>host</title>
|
||||||
|
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 100,-36.5 100,-0.5 0,-0.5"/>
|
||||||
|
<text text-anchor="middle" x="25" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
|
||||||
|
<polyline fill="none" stroke="black" points="50,-0.5 50,-36.5 "/>
|
||||||
|
<text text-anchor="middle" x="75" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
|
||||||
|
</g>
|
||||||
|
<!-- target -->
|
||||||
|
<g id="node2" class="node">
|
||||||
|
<title>target</title>
|
||||||
|
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-36.5 416.03,-36.5 416.03,-0.5 300.03,-0.5"/>
|
||||||
|
<text text-anchor="middle" x="325.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
|
||||||
|
<polyline fill="none" stroke="black" points="350.03,-0.5 350.03,-36.5 "/>
|
||||||
|
<text text-anchor="middle" x="383.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
|
||||||
|
</g>
|
||||||
|
<!-- host--target -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>host:mgmt--target:mgmt</title>
|
||||||
|
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-18.5C100,-18.5 300.03,-18.5 300.03,-18.5"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
Reference in New Issue
Block a user