From b134a632e1515076e83e4949d4cd1ebba5ca6676 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 5 Oct 2026 17:18:33 +0200 Subject: [PATCH 1/7] doc: describe how VPDs are found and what the product VPD is used for The VPD document covered the ONIE TLV encoding, but not the device tree bindings that make the system read an EEPROM, or which attributes of the VPD named "product" end up where: mainboard component, base MAC address, hostname, and factory password. Fixes #579 Signed-off-by: Joachim Wiberg --- doc/vpd.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/doc/vpd.md b/doc/vpd.md index 10b58c82b..6adf21be8 100644 --- a/doc/vpd.md +++ b/doc/vpd.md @@ -15,6 +15,93 @@ each board making up a system, figure out if a system belongs to a particular production batch, etc. +## Device Tree Bindings + +The system finds its VPDs through the `vpds` property of the +`/chosen/infix` node in the device tree (DT), a list of phandles to EEPROMs with an ONIE TLV +layout. Each EEPROM is named with an `infix,board` property, and may +be marked `infix,trusted`: + +``` +/ { + chosen { + infix { + vpds = <&vpd_cpu &vpd_product>; + }; + }; +}; + +&i2c0 { + vpd_product: eeprom@50 { + compatible = "atmel,24c02"; + reg = <0x50>; + + infix,board = "product"; + infix,trusted; + + nvmem-layout { + compatible = "onie,tlv-layout"; + + base_mac: mac-address { + #nvmem-cell-cells = <1>; + }; + }; + }; +}; +``` + +Every VPD is listed in the _ietf-hardware_ model as a component named +`vpd-`, e.g., `vpd-product` for the example above. + + +## Product VPD + +The VPD named `product` describes the device as a whole. Its +attributes are used for the following, falling back to other sources +when an attribute is missing: + +| Key | Used for | Fallback | +|-------------------|---------------------------------------------|--------------------------------------| +| `"vendor"` | `mfg-name` of the `mainboard` component | Vendor of DT `compatible` | +| `"product-name"` | `model-name` of the `mainboard` component | DT `model` property | +| `"part-number"` | `hardware-rev` of the `mainboard` component | None | +| `"serial-number"` | `serial-num` of the `mainboard` component | DT `serial-number` property | +| `"mac-address"` | Base (chassis) MAC address, see below | Lowest MAC address of all interfaces | + +The base MAC address is the `phys-address` of the `mainboard` +component. It is also what the `chassis` option of an interface's +`custom-phys-address` refers to, see [Common Interface +Settings](iface.md#chassis-mac), and the source of the `%m` format +specifier in the hostname, which the factory configuration uses to +give each device a unique name, e.g., `example-c0-ff-ee`. + +The kernel sets the MAC address of each Ethernet port from the +`nvmem-cells` property of its device tree node, which can refer to the +`mac-address` cell of the product VPD, plus an offset: + +``` +ð0 { + nvmem-cells = <&base_mac 1>; + nvmem-cell-names = "mac-address"; +}; +``` + +### Factory Password + +The factory default `admin` password hash, the `pwhash` [extension +below](#infix-specific-extensions), is read from the first VPD marked +`infix,trusted` that has one, which need not be the product VPD. It takes precedence +over a `factory-password-hash` property in the `/chosen/infix` node. +Without either, the system reports a critical bootstrap error. + +### Virtual Machines + +When running in QEMU, the product VPD is read from the `opt/vpd` +firmware configuration file, and is always trusted. Without that file +the password defaults to `admin`, and the base MAC address is the lowest +port MAC address minus one. + + ## JSON Encoding To make EEPROM binary generation less cumbersome, Infix defines a JSON From 5279ad3dbb3d895a0aac0d7a9d263ac94d75e1ff Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 5 Oct 2026 17:18:34 +0200 Subject: [PATCH 2/7] doc: update web services section for the WebUI The section still described the Web server as an information page, gave the wrong port for the Web console, 7861 instead of 7681, and did not say that disabling RESTCONF only blocks remote access, since the WebUI uses it locally. Fixes #797 Signed-off-by: Joachim Wiberg --- doc/management.md | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/doc/management.md b/doc/management.md index 411f6ce1a..be102122f 100644 --- a/doc/management.md +++ b/doc/management.md @@ -130,7 +130,7 @@ admin@example:/> The system provides a set of Web services: -- a rudimentary Web server, currently limited to an information page +- a Web management interface (WebUI) for configuration and monitoring - a RESTCONF server with equivalent management capabilities as NETCONF - a Web console service, where the shell/CLI can be accessed via HTTPS, similar to connecting via a console port or SSH @@ -139,6 +139,11 @@ There is also a *Netbrowse* Web service presenting information about the unit's neighbors, collected via mDNS (see [Discovery](discovery.md) for more details). +All of them are enabled in the factory configuration. The WebUI, Web +console, and Netbrowse are optional parts of the image. Minimal builds +have only a static information page and RESTCONF. On such an image, +the settings for the missing services are accepted but have no effect. +
admin@example:/> configure
 admin@example:/config/> edit web
 admin@example:/config/web/> help
@@ -160,19 +165,20 @@ admin@example:/config/web/> set enabled
 admin@example:/config/web/>
 
-Enabling the Web service implies that a Web server is -enabled. Currently this Web server provides generic Infix information, -as well as a link to a Web console. The Web server uses HTTPS; any -HTTP request is redirected to HTTPS. +Enabling the Web service starts a Web server on port 443 (HTTPS), and +any HTTP request on port 80 is redirected to HTTPS. It serves the +WebUI, where you log in with the same user accounts as for the CLI, +SSH, and NETCONF. The _enabled_ setting for the Web service acts as a global enable/disable setting for the other Web services (Web console, -RESTCONF and Netbrowse). +RESTCONF and Netbrowse). Disabling it stops all of them, regardless +of their own settings. ### Enable/disable Web Console The Web console service provides a terminal service similar to Console -or SSH. The Web console is secured via HTTPS on port 7861. +or SSH. The Web console is secured via HTTPS on port 7681. The Web console has its own enable/disable setting, but will only be activated if the Web service is enabled. The example below shows how @@ -185,12 +191,13 @@ admin@example:/config/web/console/> ### Enable/disable RESTCONF Service -Alternatively, the system can be managed remotely using -RESTCONF. Meaning you can `curl` it instead of using a dedicated -NETCONF client. +Alternatively, the system can be managed remotely using RESTCONF, at +`https://
/restconf`. Meaning you can `curl` it instead of +using a dedicated NETCONF client, see [RESTCONF +Scripting](scripting-restconf.md) for examples. The RESTCONF service has its own enable/disable setting, but will -only be activated if the Web service is enabled. The example below +only be activated if the Web service is enabled. The example below shows how to disable the RESTCONF service.
admin@example:/config/web/> edit restconf
@@ -198,6 +205,10 @@ admin@example:/config/web/restconf/> no enabled
 admin@example:/config/web/restconf/>
 
+> [!NOTE] +> The WebUI uses RESTCONF internally, so disabling RESTCONF only blocks +> requests from other hosts, and the WebUI keeps working. + ### HTTPS Certificate The Web server uses a TLS certificate from the central From d28ff1e116ca8cbaa623f73351cfa431fa8ab1c7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 5 Oct 2026 17:24:00 +0200 Subject: [PATCH 3/7] statd: fix VPD manufacturer never reported as mfg-name The manufacturer attribute was looked up with a misspelled key, "manufacter", so a VPD component in ietf-hardware never got its mfg-name set. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 2 ++ src/statd/python/yanger/ietf_hardware.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 89de004c4..59b1ba58e 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -29,6 +29,8 @@ All notable changes to the project are documented in this file. never showed the boot partition - LLDP neighbors were listed without their system name, descriptions, and capabilities, in the CLI, the WebUI, and the operational datastore +- The manufacturer from a VPD was not shown as `mfg-name` of its + `vpd-*` component in the operational datastore [v26.09.0][] - 2026-09-30 ------------------------- diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index b52580924..9c25bc82c 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -31,7 +31,7 @@ def vpd_component(vpd): mfgdate = datetime.datetime.strptime(vpd["data"]["manufacture-date"], "%m/%d/%Y %H:%M:%S") component["mfg-date"] = mfgdate.strftime("%Y-%m-%dT%H:%M:%SZ") - if vpd["data"].get("manufacter"): + if vpd["data"].get("manufacturer"): component["mfg-name"] = vpd["data"]["manufacturer"] if vpd["data"].get("product-name"): component["model-name"] = vpd["data"]["product-name"] From 1ae6a821c57e180482a4810c7624fa06cda645da Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 5 Oct 2026 17:25:37 +0200 Subject: [PATCH 4/7] doc: describe VLAN ingress and egress rules of a bridge The VLAN filtering bridge section did not say which frames a port accepts or drops, and claimed a default PVID of 1. Bridges are created without a default PVID, so a port without one drops untagged frames. Fixes #779 Signed-off-by: Joachim Wiberg --- doc/bridging.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/doc/bridging.md b/doc/bridging.md index 3ba38052a..d6fe7f7d4 100644 --- a/doc/bridging.md +++ b/doc/bridging.md @@ -45,8 +45,8 @@ bridge should be used instead. By default bridges in Linux do not filter based on VLAN tags. This can be enabled when creating a bridge by adding a port to a VLAN as a tagged or untagged member. Use the port default VID (PVID) setting to control -VLAN association for traffic ingressing a port untagged (default PVID: -1). +VLAN association for traffic ingressing a port untagged. There is no +default PVID, see [Ingress and Egress Rules](#ingress-and-egress-rules).
admin@example:/config/> edit interface br0
 admin@example:/config/interface/br0/> up
@@ -82,6 +82,34 @@ on this topic.
 > in VLAN 10, IP addresses can be set directly on the bridge without the
 > need for dedicated VLAN interfaces on top of the bridge.
 
+### Ingress and Egress Rules
+
+On a VLAN filtering bridge every frame belongs to a VLAN, decided when
+it enters a port.  Using `eth0` from the example above, with PVID 10
+and untagged member of VLAN 10:
+
+| Frame received on `eth0`  | Result                                         |
+|---------------------------|------------------------------------------------|
+| Untagged                  | Accepted, assigned to VLAN 10                  |
+| Priority tagged (VID 0)   | Accepted, assigned to VLAN 10, PCP is kept     |
+| Tagged with VID 10        | Accepted                                       |
+| Tagged with VID 20        | Dropped, `eth0` is not a member of VLAN 20     |
+
+The rules behind the table:
+
+- A tagged frame is accepted only if the port is a member of its VLAN.
+  Whether the membership is tagged or untagged only matters when frames
+  leave the port
+- Untagged and priority tagged frames are assigned to the port's PVID.
+  A port without a PVID drops them.  The PVID must be one of the port's
+  VLANs, otherwise it is ignored and a warning is logged
+- A frame is only forwarded to ports that are members of its VLAN.  It
+  leaves a tagged member with a VLAN tag, and an untagged member
+  without one, so the PCP of a priority tagged frame is lost on an
+  untagged port
+
+The same rules apply when the bridge is offloaded to a switch chip.
+
 
 ## Multicast Filtering and Snooping
 

From 39eb3b37eab4a97ec5b685e2ec8c00dbb42646e5 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Mon, 5 Oct 2026 18:58:14 +0200
Subject: [PATCH 5/7] doc: drop stale references to the GNS3 appliance file

The appliance moved to the GNS3 Marketplace, but the image README still
said a .gns3a file is included in the release tarball, and the branding
guide named mkgns3a.sh, which is gone, as a consumer of os-release.

Signed-off-by: Joachim Wiberg 
---
 board/common/image/image-readme/README.md | 10 ++++++----
 doc/branding.md                           |  2 +-
 2 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/board/common/image/image-readme/README.md b/board/common/image/image-readme/README.md
index 3bca78387..ea55f2029 100644
--- a/board/common/image/image-readme/README.md
+++ b/board/common/image/image-readme/README.md
@@ -103,11 +103,13 @@ Graphical Network Simulator 3 (GNS3)
 ------------------------------------
 
 GNS3 is a very powerful front-end to Qemu which takes care of creating
-virtual links between network devices running in Qemu.  This README is
-all you need to get going, alongisde it is the appliance file (.gns3a)
-that reference image files in this directory needed to load into GNS3.
+virtual links between network devices running in Qemu.  The appliance
+is available from the GNS3 Marketplace, search for Infix when adding a
+new template, and point it to the disk image in this directory.
 
-Necessary Ubuntu packages are available through the offical GNS3 PPA.
+ - https://gns3.com/marketplace/appliances/infix
+
+Necessary Ubuntu packages are available through the official GNS3 PPA.
 If you don't know what a PPA is, read up on that first:
 
  - https://launchpad.net/~gns3/+archive/ubuntu/ppa
diff --git a/doc/branding.md b/doc/branding.md
index 01e381bea..2cdce2ca6 100644
--- a/doc/branding.md
+++ b/doc/branding.md
@@ -24,7 +24,7 @@ Verify the result after a build by inspecting:
 
 - `output/images/*`: names, missing prefix, etc.
 - `output/target/etc/os-release`: this file is sourced by other build
-  scripts, e.g., `mkgns3a.sh`.  For reference, see [os-release(5)][]
+  scripts, e.g., `post-build.sh`.  For reference, see [os-release(5)][]
 
 > [!IMPORTANT]
 > To get a proper GIT revision (hash) from your OS spin, remember to set

From bb5df82290ddd6723d57e553cfdc547eba0d2318 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Mon, 5 Oct 2026 19:20:00 +0200
Subject: [PATCH 6/7] cli: warn about bridge ports with a missing or mismatched
 PVID

Bridges are created without a default PVID, so a port that is an
untagged member of a VLAN but has no PVID drops all untagged frames.
A PVID for a VLAN the port is not a member of is ignored, with only a
syslog message, and a PVID that differs from the port's untagged VLAN
puts ingress and egress traffic in different VLANs.  None of this is
visible when configuring from the CLI.

Check the candidate on check, commit, and leave, and print a warning
per port.  The configuration is still applied:

  admin@example:/config/> leave
  Warning: e1 is an untagged member of VLAN 10 on br0, but has no PVID.  Untagged frames received on e1 are dropped.
  Warning: e2 has PVID 30, but is not a member of VLAN 30 on br0.  The PVID is ignored.
  Warning: e3 is an untagged member of VLAN 10 on br0, but has PVID 20.
  admin@example:/>

Fixes #354

Signed-off-by: Joachim Wiberg 
---
 doc/ChangeLog.md                     |  3 +
 doc/bridging.md                      |  8 +++
 src/klish-plugin-infix/src/infix.c   | 98 ++++++++++++++++++++++++++++
 src/klish-plugin-infix/xml/infix.xml |  3 +
 4 files changed, 112 insertions(+)

diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index 59b1ba58e..db6afc3d7 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -21,6 +21,9 @@ All notable changes to the project are documented in this file.
   per port, and a Health card listing services that are not running, a
   pending reboot, and sensor readings.  Disk Usage no longer lists the
   read-only root filesystem
+- CLI: `check`, `commit`, and `leave` warn about bridge ports with a missing
+  or mismatched PVID, which drops untagged frames or puts them in the wrong
+  VLAN, issue #354
 
 ### Fixes
 
diff --git a/doc/bridging.md b/doc/bridging.md
index d6fe7f7d4..f9153111b 100644
--- a/doc/bridging.md
+++ b/doc/bridging.md
@@ -110,6 +110,14 @@ The rules behind the table:
 
 The same rules apply when the bridge is offloaded to a switch chip.
 
+The CLI commands `check`, `commit`, and `leave` warn about ports with a
+missing or mismatched PVID.  The configuration is applied anyway:
+
+
admin@example:/config/> leave
+Warning: eth0 is an untagged member of VLAN 10 on br0, but has no PVID.  Untagged frames received on eth0 are dropped.
+admin@example:/>
+
+ ## Multicast Filtering and Snooping diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 57b8619aa..c301f3678 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -837,6 +837,103 @@ int infix_ssh_remove_known_host(kcontext_t *ctx) return run_as_user(cd_home(ctx), argv); } +#define IF_XPATH "/ietf-interfaces:interfaces/interface" + +static int is_member(struct lyd_node *vlan, const char *mode, const char *port) +{ + char path[64]; + + snprintf(path, sizeof(path), "%s[.='%s']", mode, port); + return !lyd_find_path(vlan, path, 0, NULL); +} + +static void pvid_check_port(struct lyd_node *tree, struct lyd_node *iface) +{ + int pvid = 0, uvid = 0, num = 0, member = 0; + char untagged[64] = "", xpath[128]; + struct lyd_node *node, *vlans, *vlan; + const char *port, *br; + + port = lyd_get_value(lyd_child(iface)); + if (!lyd_find_path(iface, "infix-interfaces:bridge", 0, NULL)) + br = port; + else if (!lyd_find_path(iface, "infix-interfaces:bridge-port/bridge", 0, &node)) + br = lyd_get_value(node); + else + return; + + snprintf(xpath, sizeof(xpath), IF_XPATH "[name='%s']/infix-interfaces:bridge/vlans", br); + if (lyd_find_path(tree, xpath, 0, &vlans)) + return; + + if (!lyd_find_path(iface, "infix-interfaces:bridge-port/pvid", 0, &node)) + pvid = atoi(lyd_get_value(node)); + + LY_LIST_FOR(lyd_child(vlans), vlan) { + int vid, untag; + + if (strcmp(vlan->schema->name, "vlan")) + continue; + + untag = is_member(vlan, "untagged", port); + if (!untag && !is_member(vlan, "tagged", port)) + continue; + + vid = atoi(lyd_get_value(lyd_child(vlan))); + if (vid == pvid) + member = 1; + if (untag) { + size_t len = strlen(untagged); + + snprintf(&untagged[len], sizeof(untagged) - len, "%s%d", len ? ", " : "", vid); + uvid = vid; + num++; + } + } + + if (pvid && !member) + printf("Warning: %s has PVID %d, but is not a member of VLAN %d on %s. " + "The PVID is ignored.\n", port, pvid, pvid, br); + else if (!pvid && num) + printf("Warning: %s is an untagged member of VLAN%s %s on %s, but has no PVID. " + "Untagged frames received on %s are dropped.\n", + port, num > 1 ? "s" : "", untagged, br, port); + /* Untagged in several VLANs is an asymmetric VLAN setup, by design */ + else if (pvid && num == 1 && uvid != pvid) + printf("Warning: %s is an untagged member of VLAN %d on %s, but has PVID %d.\n", + port, uvid, br, pvid); +} + +/* + * Warn about bridge port PVIDs that drop untagged traffic or put it in + * another VLAN than the one it leaves untagged in, issue #354. + */ +int infix_pvid_check(kcontext_t *ctx) +{ + const char *xpath = IF_XPATH "/infix-interfaces:bridge/vlans | " + IF_XPATH "/infix-interfaces:bridge-port"; + sr_session_ctx_t *sess = NULL; + sr_conn_ctx_t *conn = NULL; + sr_data_t *data = NULL; + struct lyd_node *iface; + + (void)ctx; + + if (sr_connect(SR_CONN_DEFAULT, &conn)) + return 0; + if (sr_session_start(conn, SR_DS_CANDIDATE, &sess) || + sr_get_data(sess, xpath, 0, 0, 0, &data) || !data) + goto done; + + LY_LIST_FOR(lyd_child(data->tree), iface) + pvid_check_port(data->tree, iface); +done: + sr_release_data(data); + sr_disconnect(conn); + + return 0; +} + int kplugin_infix_fini(kcontext_t *ctx) { (void)ctx; @@ -867,6 +964,7 @@ int kplugin_infix_init(kcontext_t *ctx) kplugin_add_syms(plugin, ksym_new("firewall_addrsets", infix_firewall_addrsets)); kplugin_add_syms(plugin, ksym_new("firewall_addrset_action", infix_firewall_addrset_action)); kplugin_add_syms(plugin, ksym_new("set_boot_order", infix_set_boot_order)); + kplugin_add_syms(plugin, ksym_new("pvid_check", infix_pvid_check)); kplugin_add_syms(plugin, ksym_new("shell", infix_shell)); kplugin_add_syms(plugin, ksym_new("ssh_connect", infix_ssh_connect)); kplugin_add_syms(plugin, ksym_new("ssh_known_hosts", infix_ssh_known_hosts)); diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 1f7312a08..105700037 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -1145,6 +1145,7 @@ echo "Public: $pub" + replace main @@ -1192,6 +1193,7 @@ echo "Public: $pub" + @@ -1202,6 +1204,7 @@ echo "Public: $pub" + From c6e165891bddd8bc57e897ce3889eda461b33930 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 6 Oct 2026 09:33:08 +0200 Subject: [PATCH 7/7] test: syslog property filter, count only this run's messages The test sometimes failed on hardware: Expected 2 myapp messages in /var/log/myapp, got 4 /var/log persists between test runs on hardware. The cleanup step ignored the exit code of the remote rm, so when it failed, the log files from the previous run remained and every count doubled. Tag the messages with a unique token and count only lines carrying it. Also retry the cleanup on SSH transport errors and fail the test if the files cannot be removed, and wait for the last message rather than the first before checking the logs. Signed-off-by: Joachim Wiberg --- test/case/syslog/property_filter/test.py | 40 +++++++++++++++--------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/test/case/syslog/property_filter/test.py b/test/case/syslog/property_filter/test.py index b2eb5184d..0b6d6c0c6 100755 --- a/test/case/syslog/property_filter/test.py +++ b/test/case/syslog/property_filter/test.py @@ -6,6 +6,8 @@ """ +import uuid + import infamy from infamy.util import parallel, until @@ -19,6 +21,17 @@ ("test", "Warning: lowercase"), ] +# Old messages survive in /var/log on hardware, count only this run's +TOKEN = uuid.uuid4().hex[:8] + + +def count_lines(ssh, logfile, text): + """Count lines in /var/log/logfile from this run containing text""" + rc = ssh.runsh(f"cat /var/log/{logfile} 2>/dev/null") + return sum(1 for line in rc.stdout.splitlines() + if TOKEN in line and text in line) + + with infamy.Test() as test: with test.step("Set up topology and attach to target DUT"): env = infamy.Env() @@ -26,7 +39,9 @@ lambda: env.attach("target", "mgmt", "ssh")) with test.step("Clean up old log files"): - tgtssh.runsh("sudo rm -f /var/log/myapp /var/log/not-error /var/log/case-test /var/log/baseline") + rc = tgtssh.run_retry("sudo rm -f /var/log/myapp /var/log/not-error /var/log/case-test /var/log/baseline") + if rc.returncode: + test.fail("Failed removing old log files") with test.step("Configure syslog with property filters"): target.put_config_dicts({ @@ -94,41 +109,36 @@ with test.step("Send test messages"): for tag, msg in TEST_MESSAGES: - target.log(msg, severity="info", app_name=tag) - until(lambda: "Application startup" in tgtssh.runsh("cat /var/log/baseline 2>/dev/null").stdout, attempts=10) + target.log(f"{msg} {TOKEN}", severity="info", app_name=tag) + until(lambda: count_lines(tgtssh, "baseline", TEST_MESSAGES[-1][1]), attempts=10) with test.step("Verify myapp log contains only myapp messages"): - rc = tgtssh.runsh("grep -c 'myapp' /var/log/myapp 2>/dev/null") - count = int(rc.stdout.strip()) if rc.returncode == 0 else 0 + count = count_lines(tgtssh, "myapp", "myapp") if count != 2: test.fail(f"Expected 2 myapp messages in /var/log/myapp, got {count}") - rc = tgtssh.runsh("grep -c 'otherapp' /var/log/myapp 2>/dev/null") - count = int(rc.stdout.strip()) if rc.returncode == 0 else 0 + count = count_lines(tgtssh, "myapp", "otherapp") if count != 0: test.fail(f"Expected 0 otherapp messages in /var/log/myapp, got {count}") with test.step("Verify not-error log excludes ERROR messages"): - rc = tgtssh.runsh("grep -c 'test' /var/log/not-error 2>/dev/null") - count = int(rc.stdout.strip()) if rc.returncode == 0 else 0 + count = count_lines(tgtssh, "not-error", "test") if count != 3: test.fail(f"Expected 3 non-ERROR messages in /var/log/not-error, got {count}") - rc = tgtssh.runsh("grep -c 'ERROR' /var/log/not-error 2>/dev/null") - count = int(rc.stdout.strip()) if rc.returncode == 0 else 0 + count = count_lines(tgtssh, "not-error", "ERROR") if count != 0: test.fail(f"Expected 0 ERROR messages in /var/log/not-error, got {count}") with test.step("Verify case-test log matches case-insensitive 'warning'"): - rc = tgtssh.runsh("grep -c 'WARNING\\|Warning' /var/log/case-test 2>/dev/null") - count = int(rc.stdout.strip()) if rc.returncode == 0 else 0 + count = count_lines(tgtssh, "case-test", "WARNING") + \ + count_lines(tgtssh, "case-test", "Warning") if count != 2: test.fail(f"Expected 2 warning messages in /var/log/case-test, got {count}") with test.step("Verify baseline log contains all messages"): for tag, msg in TEST_MESSAGES: - rc = tgtssh.runsh(f"grep -q '{msg}' /var/log/baseline 2>/dev/null") - if rc.returncode != 0: + if not count_lines(tgtssh, "baseline", msg): test.fail(f"Expected message '{msg}' not found in /var/log/baseline") test.succeed()