|
| 1 | +/* Copyright 2019 balena |
| 2 | + * |
| 3 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | + * you may not use this file except in compliance with the License. |
| 5 | + * You may obtain a copy of the License at |
| 6 | + * |
| 7 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | + * |
| 9 | + * Unless required by applicable law or agreed to in writing, software |
| 10 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | + * See the License for the specific language governing permissions and |
| 13 | + * limitations under the License. |
| 14 | + */ |
| 15 | + |
| 16 | +'use strict'; |
| 17 | + |
| 18 | +const VOLUME_NAME = 'test_extra_firmware'; |
| 19 | +const SYSFS_PATH = '/sys/module/firmware_class/parameters/path'; |
| 20 | +const CONFIG_PATH = '/mnt/boot/config.json'; |
| 21 | +const CONFIG_BACKUP_PATH = '/mnt/state/config.json.extra-firmware-test-backup'; |
| 22 | + |
| 23 | +const backupConfig = async (context, link, test) => { |
| 24 | + test.comment('Backing up config.json...'); |
| 25 | + await context |
| 26 | + .get() |
| 27 | + .worker.executeCommandInHostOS( |
| 28 | + `cp ${CONFIG_PATH} ${CONFIG_BACKUP_PATH}`, |
| 29 | + link, |
| 30 | + ); |
| 31 | + test.comment(`config.json backed up to ${CONFIG_BACKUP_PATH}`); |
| 32 | +}; |
| 33 | + |
| 34 | +const restoreConfig = async (context, link, test) => { |
| 35 | + test.comment('Restoring config.json...'); |
| 36 | + await context |
| 37 | + .get() |
| 38 | + .worker.executeCommandInHostOS( |
| 39 | + `mv ${CONFIG_BACKUP_PATH} ${CONFIG_PATH}`, |
| 40 | + link, |
| 41 | + ); |
| 42 | + test.comment('config.json restored'); |
| 43 | +}; |
| 44 | + |
| 45 | +const createTestVolume = async (context, link, test) => { |
| 46 | + test.comment('Creating test docker volume...'); |
| 47 | + |
| 48 | + // Create volume |
| 49 | + await context |
| 50 | + .get() |
| 51 | + .worker.executeCommandInHostOS( |
| 52 | + `balena volume create ${VOLUME_NAME} || true`, |
| 53 | + link, |
| 54 | + ); |
| 55 | + |
| 56 | + // Verify volume exists |
| 57 | + const volumeExists = await context |
| 58 | + .get() |
| 59 | + .worker.executeCommandInHostOS( |
| 60 | + `balena volume inspect ${VOLUME_NAME} >/dev/null 2>&1 && echo exists || echo missing`, |
| 61 | + link, |
| 62 | + ); |
| 63 | + test.is(volumeExists.trim(), 'exists', `Docker volume ${VOLUME_NAME} should exist`); |
| 64 | + |
| 65 | + test.comment(`Volume ${VOLUME_NAME} created`); |
| 66 | +}; |
| 67 | + |
| 68 | +const configureExtraFirmwareAndVerifyServiceTriggered = async (context, link, test) => { |
| 69 | + test.comment('Configuring os.kernel.extraFirmwareVol in config.json...'); |
| 70 | + |
| 71 | + // Get the current monotonic time (in microseconds) before making the config change |
| 72 | + const monotonicTimeBefore = await context |
| 73 | + .get() |
| 74 | + .worker.executeCommandInHostOS( |
| 75 | + `cat /proc/uptime | awk '{printf "%.0f", $1 * 1000000}'`, |
| 76 | + link, |
| 77 | + ); |
| 78 | + test.comment(`Monotonic time before config change: ${monotonicTimeBefore.trim()}`); |
| 79 | + |
| 80 | + // Update config.json with the extra firmware volume |
| 81 | + await context |
| 82 | + .get() |
| 83 | + .worker.executeCommandInHostOS( |
| 84 | + `jq '.os.kernel.extraFirmwareVol = "${VOLUME_NAME}/_data"' ${CONFIG_PATH} > /tmp/config.json && cp /tmp/config.json ${CONFIG_PATH}`, |
| 85 | + link, |
| 86 | + ); |
| 87 | + |
| 88 | + // Verify the config was set |
| 89 | + const configValue = await context |
| 90 | + .get() |
| 91 | + .worker.executeCommandInHostOS( |
| 92 | + `jq -r '.os.kernel.extraFirmwareVol // empty' ${CONFIG_PATH}`, |
| 93 | + link, |
| 94 | + ); |
| 95 | + test.is(configValue.trim(), `${VOLUME_NAME}/_data`, 'config.json should have extraFirmwareVol set'); |
| 96 | + |
| 97 | + test.comment('config.json updated, waiting for service to be triggered automatically...'); |
| 98 | + |
| 99 | + // Poll for service execution (max 5 attempts, 1 second apart) |
| 100 | + // Check if ExecMainExitTimestampMonotonic is greater than our recorded time |
| 101 | + const maxAttempts = 5; |
| 102 | + let serviceTriggered = false; |
| 103 | + const timeBeforeMicros = parseInt(monotonicTimeBefore.trim(), 10); |
| 104 | + |
| 105 | + for (let i = 1; i <= maxAttempts; i++) { |
| 106 | + await new Promise(r => setTimeout(r, 1000)); |
| 107 | + |
| 108 | + const exitTimestamp = await context |
| 109 | + .get() |
| 110 | + .worker.executeCommandInHostOS( |
| 111 | + `systemctl show os-extra-firmware.service --property=ExecMainExitTimestampMonotonic --value`, |
| 112 | + link, |
| 113 | + ); |
| 114 | + |
| 115 | + const exitTimestampMicros = parseInt(exitTimestamp.trim(), 10) || 0; |
| 116 | + test.comment(`Attempt ${i}/${maxAttempts}: service exit timestamp = ${exitTimestampMicros}, threshold = ${timeBeforeMicros}`); |
| 117 | + |
| 118 | + if (exitTimestampMicros > timeBeforeMicros) { |
| 119 | + test.comment('Service was triggered automatically by config.json change'); |
| 120 | + serviceTriggered = true; |
| 121 | + break; |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + test.is(serviceTriggered, true, 'os-extra-firmware.service should be triggered automatically after config.json change'); |
| 126 | + |
| 127 | + // Verify service is active |
| 128 | + const serviceStatus = await context |
| 129 | + .get() |
| 130 | + .worker.executeCommandInHostOS( |
| 131 | + `systemctl is-active os-extra-firmware.service || true`, |
| 132 | + link, |
| 133 | + ); |
| 134 | + test.is(serviceStatus.trim(), 'active', 'os-extra-firmware.service should be active'); |
| 135 | +}; |
| 136 | + |
| 137 | +const verifySysfsPath = async (context, link, test) => { |
| 138 | + test.comment('Verifying firmware_class path in sysfs...'); |
| 139 | + |
| 140 | + const expectedPath = `/var/lib/docker/volumes/${VOLUME_NAME}/_data`; |
| 141 | + |
| 142 | + // Check if sysfs path exists |
| 143 | + const sysfsExists = await context |
| 144 | + .get() |
| 145 | + .worker.executeCommandInHostOS( |
| 146 | + `test -f ${SYSFS_PATH} && echo exists || echo missing`, |
| 147 | + link, |
| 148 | + ); |
| 149 | + |
| 150 | + if (sysfsExists.trim() !== 'exists') { |
| 151 | + test.comment(`WARNING: ${SYSFS_PATH} does not exist - kernel may not support runtime firmware path`); |
| 152 | + test.pass('Skipping sysfs check - path not available'); |
| 153 | + return; |
| 154 | + } |
| 155 | + |
| 156 | + // Read the current firmware path |
| 157 | + const currentPath = await context |
| 158 | + .get() |
| 159 | + .worker.executeCommandInHostOS( |
| 160 | + `cat ${SYSFS_PATH}`, |
| 161 | + link, |
| 162 | + ); |
| 163 | + |
| 164 | + test.is(currentPath.trim(), expectedPath, `Firmware path should be set to ${expectedPath}`); |
| 165 | + |
| 166 | + test.comment(`Firmware path correctly set: ${currentPath.trim()}`); |
| 167 | +}; |
| 168 | + |
| 169 | +const verifyKernelCmdline = async (context, link, test) => { |
| 170 | + test.comment('Verifying firmware_class.path in kernel cmdline...'); |
| 171 | + |
| 172 | + const expectedParam = `firmware_class.path=/var/lib/docker/volumes/${VOLUME_NAME}/_data`; |
| 173 | + |
| 174 | + const cmdline = await context |
| 175 | + .get() |
| 176 | + .worker.executeCommandInHostOS( |
| 177 | + `cat /proc/cmdline`, |
| 178 | + link, |
| 179 | + ); |
| 180 | + |
| 181 | + const hasParam = cmdline.includes(expectedParam); |
| 182 | + test.is(hasParam, true, `Kernel cmdline should contain ${expectedParam}`); |
| 183 | + |
| 184 | + test.comment(`Kernel cmdline: ${cmdline.trim()}`); |
| 185 | +}; |
| 186 | + |
| 187 | +const cleanup = async (context, link, test) => { |
| 188 | + test.comment('Cleaning up test environment...'); |
| 189 | + |
| 190 | + // Restore original config.json |
| 191 | + await restoreConfig(context, link, test); |
| 192 | + |
| 193 | + // Remove the test volume |
| 194 | + await context |
| 195 | + .get() |
| 196 | + .worker.executeCommandInHostOS( |
| 197 | + `balena volume rm ${VOLUME_NAME} || true`, |
| 198 | + link, |
| 199 | + ); |
| 200 | + |
| 201 | + // Restart service to clear the sysfs path |
| 202 | + await context |
| 203 | + .get() |
| 204 | + .worker.executeCommandInHostOS( |
| 205 | + `systemctl restart os-extra-firmware.service || true`, |
| 206 | + link, |
| 207 | + ); |
| 208 | + |
| 209 | + test.comment('Cleanup complete'); |
| 210 | +}; |
| 211 | + |
| 212 | +module.exports = { |
| 213 | + title: 'Extra firmware test', |
| 214 | + run: async function(test) { |
| 215 | + // Backup config.json before making changes |
| 216 | + await backupConfig(this.context, this.link, test); |
| 217 | + |
| 218 | + // Step 1: Create a docker volume with test firmware |
| 219 | + await createTestVolume(this.context, this.link, test); |
| 220 | + |
| 221 | + // Step 2: Configure config.json and verify service is triggered automatically |
| 222 | + await configureExtraFirmwareAndVerifyServiceTriggered(this.context, this.link, test); |
| 223 | + |
| 224 | + // Step 3: Verify the sysfs path is set correctly |
| 225 | + await verifySysfsPath(this.context, this.link, test); |
| 226 | + |
| 227 | + // Step 4: Reboot to verify persistence |
| 228 | + test.comment('Rebooting devicz...'); |
| 229 | + await this.context.get().worker.rebootDut(this.link); |
| 230 | + test.comment('Device rebooted successfully'); |
| 231 | + |
| 232 | + // Step 5: Verify kernel cmdline has the firmware_class.path parameter |
| 233 | + await verifyKernelCmdline(this.context, this.link, test); |
| 234 | + |
| 235 | + // Step 6: Verify sysfs path is still set after reboot |
| 236 | + await verifySysfsPath(this.context, this.link, test); |
| 237 | + |
| 238 | + // Cleanup and restore config.json |
| 239 | + await cleanup(this.context, this.link, test); |
| 240 | + }, |
| 241 | +}; |
| 242 | + |
0 commit comments