1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.tests.apex;
18 
19 import static com.google.common.truth.Truth.assertThat;
20 import static com.google.common.truth.Truth.assertWithMessage;
21 
22 import static org.junit.Assume.assumeFalse;
23 import static org.junit.Assume.assumeTrue;
24 
25 import android.cts.install.lib.host.InstallUtilsHost;
26 
27 import com.android.tests.rollback.host.AbandonSessionsRule;
28 import com.android.tradefed.device.DeviceNotAvailableException;
29 import com.android.tradefed.device.ITestDevice;
30 import com.android.tradefed.device.ITestDevice.ApexInfo;
31 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
32 import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
33 
34 import org.junit.After;
35 import org.junit.Before;
36 import org.junit.Rule;
37 import org.junit.Test;
38 import org.junit.runner.RunWith;
39 
40 import java.io.File;
41 import java.time.Duration;
42 import java.util.Set;
43 
44 /**
45  * Test for automatic recovery of apex update that causes boot loop.
46  */
47 @RunWith(DeviceJUnit4ClassRunner.class)
48 public class ApexRollbackTests extends BaseHostJUnit4Test {
49     private final InstallUtilsHost mHostUtils = new InstallUtilsHost(this);
50     @Rule
51     public AbandonSessionsRule mHostTestRule = new AbandonSessionsRule(this);
52 
53     private boolean mWasAdbRoot = false;
54 
55     @Before
setUp()56     public void setUp() throws Exception {
57         mHostUtils.uninstallShimApexIfNecessary();
58         resetProperties();
59         mWasAdbRoot = getDevice().isAdbRoot();
60         if (!mWasAdbRoot) {
61             assumeTrue("Requires root", getDevice().enableAdbRoot());
62         }
63     }
64 
65     /**
66      * Uninstalls any version greater than 1 of shim apex and reboots the device if necessary
67      * to complete the uninstall.
68      */
69     @After
tearDown()70     public void tearDown() throws Exception {
71         mHostUtils.uninstallShimApexIfNecessary();
72         resetProperties();
73         if (!mWasAdbRoot) {
74             getDevice().disableAdbRoot();
75         }
76     }
77 
resetProperties()78     private void resetProperties() throws Exception {
79         resetProperty("persist.debug.trigger_watchdog.apex");
80         resetProperty("persist.debug.trigger_updatable_crashing_for_testing");
81         resetProperty("persist.debug.trigger_reboot_after_activation");
82         resetProperty("persist.debug.trigger_reboot_twice_after_activation");
83     }
84 
resetProperty(String propertyName)85     private void resetProperty(String propertyName) throws Exception {
86         assertWithMessage("Failed to reset value of property %s", propertyName).that(
87                 getDevice().setProperty(propertyName, "")).isTrue();
88     }
89 
90     /**
91      * Test for automatic recovery of apex update that causes boot loop.
92      */
93     @Test
testAutomaticBootLoopRecovery()94     public void testAutomaticBootLoopRecovery() throws Exception {
95         assumeTrue("Device does not support updating APEX", mHostUtils.isApexUpdateSupported());
96         ITestDevice device = getDevice();
97         // Skip this test if there is already crashing process on device
98         boolean hasCrashingProcess =
99                 device.getBooleanProperty("sys.init.updatable_crashing", false);
100         String crashingProcess = device.getProperty("sys.init.updatable_crashing_process_name");
101         assumeFalse(
102                 "Device already has a crashing process: " + crashingProcess, hasCrashingProcess);
103         File apexFile = mHostUtils.getTestFile("com.android.apex.cts.shim.v2.apex");
104 
105         // To simulate an apex update that causes a boot loop, we install a
106         // trigger_watchdog.rc file that arranges for a trigger_watchdog.sh
107         // script to be run at boot. The trigger_watchdog.sh script checks if
108         // the apex version specified in the property
109         // persist.debug.trigger_watchdog.apex is installed. If so,
110         // trigger_watchdog.sh repeatedly kills the system server causing a
111         // boot loop.
112         assertThat(device.setProperty("persist.debug.trigger_watchdog.apex",
113                 "com.android.apex.cts.shim@2")).isTrue();
114         String error = mHostUtils.installStagedPackage(apexFile);
115         assertThat(error).isNull();
116 
117         String sessionIdToCheck = device.executeShellCommand("pm get-stagedsessions --only-ready "
118                 + "--only-parent --only-sessionid").trim();
119         assertThat(sessionIdToCheck).isNotEmpty();
120 
121         // After we reboot the device, we expect the device to go into boot
122         // loop from trigger_watchdog.sh. Native watchdog should detect and
123         // report the boot loop, causing apexd to roll back to the previous
124         // version of the apex and force reboot. When the device comes up
125         // after the forced reboot, trigger_watchdog.sh will see the different
126         // version of the apex and refrain from forcing a boot loop, so the
127         // device will be recovered.
128         device.reboot();
129 
130         ApexInfo ctsShimV1 = new ApexInfo("com.android.apex.cts.shim", 1L);
131         ApexInfo ctsShimV2 = new ApexInfo("com.android.apex.cts.shim", 2L);
132         Set<ApexInfo> activatedApexes = device.getActiveApexes();
133         assertThat(activatedApexes).contains(ctsShimV1);
134         assertThat(activatedApexes).doesNotContain(ctsShimV2);
135 
136         // Assert that a session has failed with the expected reason
137         String sessionInfo = device.executeShellCommand("cmd -w apexservice getStagedSessionInfo "
138                     + sessionIdToCheck);
139         assertThat(sessionInfo).contains("revertReason: zygote");
140     }
141 
142     /**
143      * Test to verify that a device that does not support checkpointing will not revert a session
144      * if it reboots during boot.
145      */
146     @Test
testSessionNotRevertedWithCheckpointingDisabled()147     public void testSessionNotRevertedWithCheckpointingDisabled() throws Exception {
148         assumeTrue("Device does not support updating APEX", mHostUtils.isApexUpdateSupported());
149         assumeFalse("Fs checkpointing is enabled", mHostUtils.isCheckpointSupported());
150 
151         File apexFile = mHostUtils.getTestFile("com.android.apex.cts.shim.v2.apex");
152 
153         ITestDevice device = getDevice();
154         assertThat(device.setProperty("persist.debug.trigger_reboot_after_activation",
155                 "com.android.apex.cts.shim@2.apex")).isTrue();
156         assertThat(device.setProperty("debug.trigger_reboot_once_after_activation",
157                 "1")).isTrue();
158 
159         String error = mHostUtils.installStagedPackage(apexFile);
160         assertThat(error).isNull();
161 
162         String sessionIdToCheck = device.executeShellCommand("pm get-stagedsessions --only-ready "
163                 + "--only-parent --only-sessionid").trim();
164         assertThat(sessionIdToCheck).isNotEmpty();
165 
166         // After we reboot the device, the apexd session should be activated as normal. After this,
167         // trigger_reboot.sh will reboot the device before the system server boots.
168         device.reboot();
169 
170         ApexInfo ctsShimV1 = new ApexInfo("com.android.apex.cts.shim", 1L);
171         ApexInfo ctsShimV2 = new ApexInfo("com.android.apex.cts.shim", 2L);
172         String stagedSessionInfo = getStagedSession(sessionIdToCheck);
173         assertThat(stagedSessionInfo).contains("isApplied = true");
174 
175         Set<ApexInfo> activatedApexes = device.getActiveApexes();
176         assertThat(activatedApexes).contains(ctsShimV2);
177         assertThat(activatedApexes).doesNotContain(ctsShimV1);
178     }
179 
180     /**
181      * Test to verify that rebooting twice when a session is activated will cause the session to
182      * be reverted due to filesystem checkpointing.
183      */
184     @Test
testCheckpointingRevertsSession()185     public void testCheckpointingRevertsSession() throws Exception {
186         assumeTrue("Device does not support updating APEX", mHostUtils.isApexUpdateSupported());
187         assumeTrue("Device doesn't support fs checkpointing", mHostUtils.isCheckpointSupported());
188 
189         File apexFile = mHostUtils.getTestFile("com.android.apex.cts.shim.v2.apex");
190 
191         ITestDevice device = getDevice();
192         assertThat(device.setProperty("persist.debug.trigger_reboot_after_activation",
193                 "com.android.apex.cts.shim@2.apex")).isTrue();
194         assertThat(device.setProperty("persist.debug.trigger_reboot_twice_after_activation",
195                 "1")).isTrue();
196         String error = mHostUtils.installStagedPackage(apexFile);
197         assertThat(error).isNull();
198 
199         String sessionIdToCheck = device.executeShellCommand("pm get-stagedsessions --only-ready "
200                 + "--only-parent --only-sessionid").trim();
201         assertThat(sessionIdToCheck).isNotEmpty();
202 
203         // After we reboot the device, the apexd session should be activated as normal. After this,
204         // trigger_reboot.sh will reboot the device before the system server boots. Checkpointing
205         // will kick in, and at the next boot any non-finalized sessions will be reverted.
206         device.reboot();
207 
208         ApexInfo ctsShimV1 = new ApexInfo("com.android.apex.cts.shim", 1L);
209         ApexInfo ctsShimV2 = new ApexInfo("com.android.apex.cts.shim", 2L);
210         String stagedSessionInfo = getStagedSession(sessionIdToCheck);
211         assertThat(stagedSessionInfo).contains("isFailed = true");
212 
213         Set<ApexInfo> activatedApexes = device.getActiveApexes();
214         assertThat(activatedApexes).contains(ctsShimV1);
215         assertThat(activatedApexes).doesNotContain(ctsShimV2);
216     }
217 
218     /**
219      * Test to verify that rebooting once upon apex activation does not cause checkpointing to kick
220      * in and revert a session, since the checkpointing retry count should be 2.
221      */
222     @Test
testRebootingOnceDoesNotRevertSession()223     public void testRebootingOnceDoesNotRevertSession() throws Exception {
224         assumeTrue("Device does not support updating APEX", mHostUtils.isApexUpdateSupported());
225         assumeTrue("Device doesn't support fs checkpointing", mHostUtils.isCheckpointSupported());
226 
227         File apexFile = mHostUtils.getTestFile("com.android.apex.cts.shim.v2.apex");
228 
229         ITestDevice device = getDevice();
230         assertThat(device.setProperty("persist.debug.trigger_reboot_after_activation",
231                 "com.android.apex.cts.shim@2.apex")).isTrue();
232         assertThat(device.setProperty("debug.trigger_reboot_once_after_activation",
233                 "1")).isTrue();
234         String error = mHostUtils.installStagedPackage(apexFile);
235         assertThat(error).isNull();
236 
237         String sessionIdToCheck = device.executeShellCommand("pm get-stagedsessions --only-ready "
238                 + "--only-parent --only-sessionid").trim();
239         assertThat(sessionIdToCheck).isNotEmpty();
240 
241         // After we reboot the device, the apexd session should be activated as normal. After this,
242         // trigger_reboot.sh will reboot the device before the system server boots. Checkpointing
243         // will kick in, and at the next boot any non-finalized sessions will be reverted.
244         device.reboot();
245 
246         ApexInfo ctsShimV1 = new ApexInfo("com.android.apex.cts.shim", 1L);
247         ApexInfo ctsShimV2 = new ApexInfo("com.android.apex.cts.shim", 2L);
248         String stagedSessionInfo = getStagedSession(sessionIdToCheck);
249         assertThat(stagedSessionInfo).contains("isApplied = true");
250 
251         Set<ApexInfo> activatedApexes = device.getActiveApexes();
252         assertThat(activatedApexes).contains(ctsShimV2);
253         assertThat(activatedApexes).doesNotContain(ctsShimV1);
254     }
255 
256     // TODO(ioffe): check that we recover from the boot loop in case of userspace reboot.
257 
258     /**
259      * Test to verify that apexd won't boot loop a device in case {@code sys.init
260      * .updatable_crashing} is {@code true} and there is no apex session to revert.
261      */
262     @Test
testApexdDoesNotBootLoopDeviceIfThereIsNothingToRevert()263     public void testApexdDoesNotBootLoopDeviceIfThereIsNothingToRevert() throws Exception {
264         assumeTrue("Device does not support updating APEX", mHostUtils.isApexUpdateSupported());
265         // On next boot trigger setprop sys.init.updatable_crashing 1, which will trigger a
266         // revert mechanism in apexd. Since there is nothing to revert, this should be a no-op
267         // and device will boot successfully.
268         assertThat(getDevice().setProperty("persist.debug.trigger_updatable_crashing_for_testing",
269                 "1")).isTrue();
270         getDevice().reboot();
271         assertWithMessage("Device didn't boot in 1 minute").that(
272                 getDevice().waitForBootComplete(Duration.ofMinutes(1).toMillis())).isTrue();
273         // Verify that property was set to true.
274         assertThat(getDevice().getBooleanProperty("sys.init.updatable_crashing", false)).isTrue();
275     }
276 
277     /**
278      * Test to verify that if a hard reboot is triggered during userspace reboot boot
279      * sequence, an apex update will not be reverted.
280      */
281     @Test
testFailingUserspaceReboot_doesNotRevertUpdate()282     public void testFailingUserspaceReboot_doesNotRevertUpdate() throws Exception {
283         assumeTrue("Device does not support updating APEX", mHostUtils.isApexUpdateSupported());
284         assumeTrue("Device doesn't support userspace reboot",
285                 getDevice().getBooleanProperty("init.userspace_reboot.is_supported", false));
286         assumeTrue("Device doesn't support fs checkpointing", mHostUtils.isCheckpointSupported());
287 
288         File apexFile = mHostUtils.getTestFile("com.android.apex.cts.shim.v2.apex");
289         // Simulate failure in userspace reboot by triggering a full reboot in the middle of the
290         // boot sequence.
291         assertThat(getDevice().setProperty("test.apex_revert_test_force_reboot", "1")).isTrue();
292         String error = mHostUtils.installStagedPackage(apexFile);
293         assertWithMessage("Failed to stage com.android.apex.cts.shim.v2.apex : %s", error).that(
294                 error).isNull();
295         // After we reboot the device, apexd will apply the update
296         getDevice().rebootUserspace();
297         // Verify that hard reboot happened.
298         assertThat(getDevice().getIntProperty("sys.init.userspace_reboot.last_finished",
299                 -1)).isEqualTo(-1);
300         Set<ApexInfo> activatedApexes = getDevice().getActiveApexes();
301         assertThat(activatedApexes).doesNotContain(new ApexInfo("com.android.apex.cts.shim", 1L));
302         assertThat(activatedApexes).contains(new ApexInfo("com.android.apex.cts.shim", 2L));
303     }
304 
305     /**
306      * Test to verify that if a hard reboot is triggered before executing init executes {@code
307      * /system/bin/vdc checkpoint markBootAttempt} of userspace reboot boot sequence, apex update
308      * still will be installed.
309      */
310     @Test
testUserspaceRebootFailedShutdownSequence_doesNotRevertUpdate()311     public void testUserspaceRebootFailedShutdownSequence_doesNotRevertUpdate() throws Exception {
312         assumeTrue("Device does not support updating APEX", mHostUtils.isApexUpdateSupported());
313         assumeTrue("Device doesn't support userspace reboot",
314                 getDevice().getBooleanProperty("init.userspace_reboot.is_supported", false));
315         assumeTrue("Device doesn't support fs checkpointing", mHostUtils.isCheckpointSupported());
316 
317         File apexFile = mHostUtils.getTestFile("com.android.apex.cts.shim.v2.apex");
318         // Simulate failure in userspace reboot by triggering a full reboot in the middle of the
319         // boot sequence.
320         assertThat(getDevice().setProperty("test.apex_userspace_reboot_simulate_shutdown_failed",
321                 "1")).isTrue();
322         String error = mHostUtils.installStagedPackage(apexFile);
323         assertWithMessage("Failed to stage com.android.apex.cts.shim.v2.apex : %s", error).that(
324                 error).isNull();
325         // After the userspace reboot started, we simulate it's failure by rebooting device during
326         // on userspace-reboot-requested action. Since boot attempt hasn't been marked yet, next
327         // boot will apply the update.
328         assertThat(getDevice().getIntProperty("test.apex_userspace_reboot_simulate_shutdown_failed",
329                 0)).isEqualTo(1);
330         getDevice().rebootUserspace();
331         // Verify that hard reboot happened.
332         assertThat(getDevice().getIntProperty("sys.init.userspace_reboot.last_finished",
333                 -1)).isEqualTo(-1);
334         Set<ApexInfo> activatedApexes = getDevice().getActiveApexes();
335         assertThat(activatedApexes).contains(new ApexInfo("com.android.apex.cts.shim", 2L));
336     }
337 
338     /**
339      * Test to verify that if a hard reboot is triggered around the time of
340      * executing {@code /system/bin/vdc checkpoint markBootAttempt} of userspace reboot boot
341      * sequence, apex update will still be installed.
342      */
343     @Test
testUserspaceRebootFailedRemount_revertsUpdate()344     public void testUserspaceRebootFailedRemount_revertsUpdate() throws Exception {
345         assumeTrue("Device does not support updating APEX", mHostUtils.isApexUpdateSupported());
346         assumeTrue("Device doesn't support userspace reboot",
347                 getDevice().getBooleanProperty("init.userspace_reboot.is_supported", false));
348         assumeTrue("Device doesn't support fs checkpointing", mHostUtils.isCheckpointSupported());
349 
350         File apexFile = mHostUtils.getTestFile("com.android.apex.cts.shim.v2.apex");
351         // Simulate failure in userspace reboot by triggering a full reboot in the middle of the
352         // boot sequence.
353         assertThat(getDevice().setProperty("test.apex_userspace_reboot_simulate_remount_failed",
354                 "1")).isTrue();
355         String error = mHostUtils.installStagedPackage(apexFile);
356         assertWithMessage("Failed to stage com.android.apex.cts.shim.v2.apex : %s", error).that(
357                 error).isNull();
358         // After we reboot the device, apexd will apply the update
359         getDevice().rebootUserspace();
360         // Verify that hard reboot happened.
361         assertThat(getDevice().getIntProperty("sys.init.userspace_reboot.last_finished",
362                 -1)).isEqualTo(-1);
363         Set<ApexInfo> activatedApexes = getDevice().getActiveApexes();
364         assertThat(activatedApexes).doesNotContain(new ApexInfo("com.android.apex.cts.shim", 1L));
365         assertThat(activatedApexes).contains(new ApexInfo("com.android.apex.cts.shim", 2L));
366     }
367 
368     /**
369      * Test to verify that boot cleanup logic in apexd is triggered when there is a crash looping
370      * process, but there is nothing to revert.
371      */
372     @Test
testBootCompletedCleanupHappensEvenWhenThereIsCrashingProcess()373     public void testBootCompletedCleanupHappensEvenWhenThereIsCrashingProcess() throws Exception {
374         assumeTrue("Device does not support updating APEX", mHostUtils.isApexUpdateSupported());
375         assumeTrue("Device requires root", getDevice().isAdbRoot());
376         try {
377             // On next boot trigger setprop sys.init.updatable_crashing 1, which will trigger a
378             // revert mechanism in apexd. Since there is nothing to revert, this should be a no-op
379             // and device will boot successfully.
380             getDevice().setProperty("persist.debug.trigger_updatable_crashing_for_testing", "1");
381             assertThat(getDevice().pushFile(mHostUtils.getTestFile("apex.apexd_test_v2.apex"),
382                     "/data/apex/active/apexd_test_v2.apex")).isTrue();
383             getDevice().reboot();
384             assertWithMessage("Timed out waiting for device to boot").that(
385                     getDevice().waitForBootComplete(Duration.ofMinutes(2).toMillis())).isTrue();
386             // Verify that property was set to true.
387             assertThat(
388                     getDevice().getBooleanProperty("sys.init.updatable_crashing", false)).isTrue();
389             final Set<ITestDevice.ApexInfo> activeApexes = getDevice().getActiveApexes();
390             ITestDevice.ApexInfo testApex = new ITestDevice.ApexInfo(
391                     "com.android.apex.cts.shim", 2L);
392             assertThat(activeApexes).doesNotContain(testApex);
393             mHostUtils.waitForFileDeleted("/data/apex/active/apexd_test_v2.apex",
394                     Duration.ofMinutes(3));
395         } finally {
396             getDevice().executeShellV2Command("rm /data/apex/active/apexd_test_v2.apex");
397         }
398     }
399 
400     /**
401      * Test reason for revert is properly logged during boot loops
402      */
403     @Test
testReasonForRevertIsLoggedDuringBootloop()404     public void testReasonForRevertIsLoggedDuringBootloop() throws Exception {
405         assumeTrue("Device does not support updating APEX", mHostUtils.isApexUpdateSupported());
406         assumeTrue("Fs checkpointing is enabled", mHostUtils.isCheckpointSupported());
407 
408         ITestDevice device = getDevice();
409         // Skip this test if there is already crashing process on device
410         final boolean hasCrashingProcess =
411                 device.getBooleanProperty("sys.init.updatable_crashing", false);
412         final String crashingProcess =
413                 device.getProperty("sys.init.updatable_crashing_process_name");
414         assumeFalse(
415                 "Device already has a crashing process: " + crashingProcess, hasCrashingProcess);
416         final File apexFile = mHostUtils.getTestFile("com.android.apex.cts.shim.v2.apex");
417 
418         // To simulate an apex update that causes a boot loop, we install a
419         // trigger_watchdog.rc file that arranges for a trigger_watchdog.sh
420         // script to be run at boot. The trigger_watchdog.sh script checks if
421         // the apex version specified in the property
422         // persist.debug.trigger_watchdog.apex is installed. If so,
423         // trigger_watchdog.sh repeatedly kills the system server causing a
424         // boot loop.
425         assertThat(device.setProperty("persist.debug.trigger_watchdog.apex",
426                 "com.android.apex.cts.shim@2")).isTrue();
427         final String error = mHostUtils.installStagedPackage(apexFile);
428         assertThat(error).isNull();
429 
430         final String sessionIdToCheck = device.executeShellCommand("pm get-stagedsessions "
431                 + "--only-ready --only-parent --only-sessionid").trim();
432         assertThat(sessionIdToCheck).isNotEmpty();
433 
434         // After we reboot the device, we expect the device to go into boot
435         // loop from trigger_watchdog.sh. Native watchdog should detect and
436         // report the boot loop, causing apexd to roll back to the previous
437         // version of the apex and force reboot. When the device comes up
438         // after the forced reboot, trigger_watchdog.sh will see the different
439         // version of the apex and refrain from forcing a boot loop, so the
440         // device will be recovered.
441         device.reboot();
442 
443         final ApexInfo ctsShimV1 = new ApexInfo("com.android.apex.cts.shim", 1L);
444         final ApexInfo ctsShimV2 = new ApexInfo("com.android.apex.cts.shim", 2L);
445         final Set<ApexInfo> activatedApexes = device.getActiveApexes();
446         assertThat(activatedApexes).contains(ctsShimV1);
447         assertThat(activatedApexes).doesNotContain(ctsShimV2);
448 
449         // Assert that a session has failed with the expected reason
450         final String stagedSessionString = getStagedSession(sessionIdToCheck);
451         assertThat(stagedSessionString).contains("Session reverted due to crashing native process");
452     }
453 
getStagedSession(String sessionId)454     String getStagedSession(String sessionId) throws DeviceNotAvailableException {
455         final String[] lines = getDevice().executeShellCommand(
456                 "pm get-stagedsessions").split("\n");
457         for (int i = 0; i < lines.length; i++) {
458             if (lines[i].startsWith("sessionId = " + sessionId + ";")) {
459                 // Join all lines realted to this session
460                 final StringBuilder result = new StringBuilder(lines[i]);
461                 for (int j = i + 1; j < lines.length; j++) {
462                     if (lines[j].startsWith("sessionId = ")) {
463                         // A new session block has started
464                         break;
465                     }
466                     result.append(lines[j]);
467                 }
468                 return result.toString();
469             }
470         }
471         return "";
472     }
473 }
474