Skip to content

Commit c7752b1

Browse files
committed
feat: add uninstall lifecycle
1 parent b0b767b commit c7752b1

File tree

11 files changed

+545
-6
lines changed

11 files changed

+545
-6
lines changed

src/integrationtests/java/com/aws/greengrass/integrationtests/lifecyclemanager/GenericExternalServiceIntegTest.java

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,125 @@ void GIVEN_running_service_WHEN_pause_resume_requested_THEN_pause_resume_Service
884884
assertFalse(freezerManager.isComponentFrozen(component.getServiceName()));
885885
}
886886

887+
// AC1 & AC3: End-to-end uninstall with environment variables
888+
@Test
889+
void GIVEN_component_with_uninstall_script_WHEN_uninstall_THEN_script_executes_successfully() throws Exception {
890+
ConfigPlatformResolver.initKernelWithMultiPlatformConfig(kernel,
891+
getClass().getResource("uninstall_success_config.yaml"));
892+
893+
CountDownLatch mainRunningLatch = new CountDownLatch(1);
894+
kernel.getContext().addGlobalStateChangeListener((service, oldState, newState) -> {
895+
if (service.getName().equals("UninstallTestComponent") && newState.equals(State.RUNNING)) {
896+
mainRunningLatch.countDown();
897+
}
898+
});
899+
900+
kernel.launch();
901+
assertTrue(mainRunningLatch.await(60, TimeUnit.SECONDS), "component running");
902+
903+
GenericExternalService component = (GenericExternalService) kernel.locate("UninstallTestComponent");
904+
assertThat(component.getState(), is(State.RUNNING));
905+
906+
component.requestUninstall();
907+
Thread.sleep(2000);
908+
909+
assertThat(component.getState(), is(State.UNINSTALLED));
910+
}
911+
912+
// AC2: Uninstall script failure doesn't fail deployment
913+
@Test
914+
void GIVEN_component_with_failing_uninstall_script_WHEN_uninstall_THEN_component_still_uninstalled() throws Exception {
915+
ConfigPlatformResolver.initKernelWithMultiPlatformConfig(kernel,
916+
getClass().getResource("uninstall_failure_config.yaml"));
917+
918+
CountDownLatch mainRunningLatch = new CountDownLatch(1);
919+
kernel.getContext().addGlobalStateChangeListener((service, oldState, newState) -> {
920+
if (service.getName().equals("FailingUninstallComponent") && newState.equals(State.RUNNING)) {
921+
mainRunningLatch.countDown();
922+
}
923+
});
924+
925+
kernel.launch();
926+
assertTrue(mainRunningLatch.await(60, TimeUnit.SECONDS), "component running");
927+
928+
GenericExternalService component = (GenericExternalService) kernel.locate("FailingUninstallComponent");
929+
component.requestUninstall();
930+
Thread.sleep(3000);
931+
932+
assertThat(component.getState(), is(State.UNINSTALLED));
933+
}
934+
935+
// AC7: State transitions
936+
@Test
937+
void GIVEN_component_WHEN_uninstall_requested_THEN_goes_through_UNINSTALLING_to_UNINSTALLED_states() throws Exception {
938+
ConfigPlatformResolver.initKernelWithMultiPlatformConfig(kernel,
939+
getClass().getResource("uninstall_state_transition_config.yaml"));
940+
941+
List<State> stateTransitions = new CopyOnWriteArrayList<>();
942+
CountDownLatch mainRunningLatch = new CountDownLatch(1);
943+
CountDownLatch uninstalledLatch = new CountDownLatch(1);
944+
945+
kernel.getContext().addGlobalStateChangeListener((service, oldState, newState) -> {
946+
if (service.getName().equals("StateTransitionComponent")) {
947+
stateTransitions.add(newState);
948+
if (newState.equals(State.RUNNING)) {
949+
mainRunningLatch.countDown();
950+
} else if (newState.equals(State.UNINSTALLED)) {
951+
uninstalledLatch.countDown();
952+
}
953+
}
954+
});
955+
956+
kernel.launch();
957+
assertTrue(mainRunningLatch.await(60, TimeUnit.SECONDS), "component running");
958+
959+
GenericExternalService component = (GenericExternalService) kernel.locate("StateTransitionComponent");
960+
component.requestUninstall();
961+
962+
assertTrue(uninstalledLatch.await(10, TimeUnit.SECONDS), "component uninstalled");
963+
964+
assertTrue(stateTransitions.contains(State.UNINSTALLING), "Should transition through UNINSTALLING");
965+
assertTrue(stateTransitions.contains(State.UNINSTALLED), "Should reach UNINSTALLED");
966+
}
967+
968+
// AC4 & AC5: Timeout configuration and handling
969+
@Test
970+
void GIVEN_component_with_timeout_uninstall_script_WHEN_timeout_occurs_THEN_component_uninstalled() throws Exception {
971+
ConfigPlatformResolver.initKernelWithMultiPlatformConfig(kernel,
972+
getClass().getResource("uninstall_timeout_config.yaml"));
973+
974+
CountDownLatch mainRunningLatch = new CountDownLatch(1);
975+
kernel.getContext().addGlobalStateChangeListener((service, oldState, newState) -> {
976+
if (service.getName().equals("TimeoutUninstallComponent") && newState.equals(State.RUNNING)) {
977+
mainRunningLatch.countDown();
978+
}
979+
});
980+
981+
kernel.launch();
982+
assertTrue(mainRunningLatch.await(60, TimeUnit.SECONDS), "component running");
983+
984+
GenericExternalService component = (GenericExternalService) kernel.locate("TimeoutUninstallComponent");
985+
986+
CountDownLatch uninstalledLatch = new CountDownLatch(1);
987+
kernel.getContext().addGlobalStateChangeListener((service, oldState, newState) -> {
988+
if (service.getName().equals("TimeoutUninstallComponent") && newState.equals(State.UNINSTALLED)) {
989+
uninstalledLatch.countDown();
990+
}
991+
});
992+
993+
long startTime = System.currentTimeMillis();
994+
component.requestUninstall();
995+
996+
assertTrue(uninstalledLatch.await(10, TimeUnit.SECONDS), "component should reach UNINSTALLED");
997+
long duration = System.currentTimeMillis() - startTime;
998+
999+
// Timeout is 5s, script sleeps 10s. Should complete around 5s, not 10s
1000+
assertTrue(duration >= 4000, "Should have waited at least ~5s for timeout");
1001+
assertTrue(duration < 7000, "Should timeout at 5s, not wait full 10s for script");
1002+
1003+
assertThat(component.getState(), is(State.UNINSTALLED));
1004+
}
1005+
8871006
private boolean isCgroupV2Supported() {
8881007
return Files.exists(Paths.get("/sys/fs/cgroup/cgroup.controllers"));
8891008
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
services:
3+
aws.greengrass.Nucleus:
4+
configuration:
5+
runWithDefault:
6+
posixUser: nobody
7+
windowsUser: integ-tester
8+
9+
FailingUninstallComponent:
10+
lifecycle:
11+
posix:
12+
run: echo "Component running" && sleep 30
13+
uninstall: |
14+
echo "Uninstall script starting"
15+
echo "Simulating uninstall failure"
16+
exit 1
17+
windows:
18+
run: echo Component running && timeout /t 30
19+
uninstall: |
20+
echo Uninstall script starting
21+
echo Simulating uninstall failure
22+
exit /b 1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
services:
3+
aws.greengrass.Nucleus:
4+
configuration:
5+
runWithDefault:
6+
posixUser: nobody
7+
windowsUser: integ-tester
8+
9+
StateTransitionComponent:
10+
lifecycle:
11+
posix:
12+
run: echo "Component running" && sleep 30
13+
uninstall: |
14+
echo "Uninstall script executing"
15+
sleep 2
16+
echo "Uninstall completed"
17+
windows:
18+
run: echo Component running && timeout /t 30
19+
uninstall: |
20+
echo Uninstall script executing
21+
timeout /t 2
22+
echo Uninstall completed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
services:
3+
aws.greengrass.Nucleus:
4+
configuration:
5+
runWithDefault:
6+
posixUser: nobody
7+
windowsUser: integ-tester
8+
9+
UninstallTestComponent:
10+
lifecycle:
11+
posix:
12+
run: echo "Component running" && sleep 30
13+
uninstall: |
14+
echo "Uninstall script executing"
15+
echo "GREENGRASS_LIFECYCLE_EVENT: $GREENGRASS_LIFECYCLE_EVENT"
16+
echo "GREENGRASS_COMPONENT_NAME: $GREENGRASS_COMPONENT_NAME"
17+
echo "GREENGRASS_COMPONENT_VERSION: $GREENGRASS_COMPONENT_VERSION"
18+
echo "Uninstall completed successfully"
19+
windows:
20+
run: echo Component running && timeout /t 30
21+
uninstall: |
22+
echo Uninstall script executing
23+
echo GREENGRASS_LIFECYCLE_EVENT: %GREENGRASS_LIFECYCLE_EVENT%
24+
echo GREENGRASS_COMPONENT_NAME: %GREENGRASS_COMPONENT_NAME%
25+
echo GREENGRASS_COMPONENT_VERSION: %GREENGRASS_COMPONENT_VERSION%
26+
echo Uninstall completed successfully
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
services:
3+
aws.greengrass.Nucleus:
4+
configuration:
5+
runWithDefault:
6+
posixUser: nobody
7+
windowsUser: integ-tester
8+
9+
TimeoutUninstallComponent:
10+
lifecycle:
11+
posix:
12+
run: echo "Component running" && sleep 30
13+
uninstall:
14+
script: |
15+
echo "Uninstall script starting - will timeout"
16+
sleep 10
17+
echo "This should not be reached due to timeout"
18+
timeout: 5
19+
windows:
20+
run: echo Component running && timeout /t 30
21+
uninstall:
22+
script: |
23+
echo Uninstall script starting - will timeout
24+
timeout /t 10
25+
echo This should not be reached due to timeout
26+
timeout: 5

src/main/java/com/aws/greengrass/dependency/State.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,14 @@ public enum State {
5454
* The service has done it's job and has no more to do. May be restarted
5555
* (for example, a monitoring task that will be restarted by a timer)
5656
*/
57-
FINISHED(true, false, true, "Finished");
57+
FINISHED(true, false, true, "Finished"),
58+
59+
/**
60+
* Service is running uninstall script before permanent removal.
61+
*/
62+
UNINSTALLING(true, false, true, "Uninstalling"),
63+
64+
UNINSTALLED(true, false, true, "Uninstalled");
5865

5966
private final boolean happy;
6067
private final boolean running;
@@ -115,6 +122,7 @@ public boolean isStoppable() {
115122
}
116123

117124
public boolean isClosable() {
118-
return this.equals(ERRORED) || this.equals(BROKEN) || this.equals(FINISHED) || this.equals(NEW);
125+
return this.equals(ERRORED) || this.equals(BROKEN) || this.equals(FINISHED)
126+
|| this.equals(NEW) || this.equals(UNINSTALLED);
119127
}
120128
}

src/main/java/com/aws/greengrass/deployment/DeploymentConfigMerger.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,11 @@ public void removeObsoleteServices() throws InterruptedException, ServiceUpdateE
392392
return true;
393393
}).collect(Collectors.toSet());
394394
logger.atInfo(MERGE_CONFIG_EVENT_KEY).kv("service-to-remove", servicesToRemove).log("Removing services");
395+
396+
// Request uninstall for each service before closing
397+
for (GreengrassService service : ggServicesToRemove) {
398+
service.requestUninstall();
399+
}
395400
// waiting for removed service to close before removing reference and config entry
396401
for (GreengrassService service : ggServicesToRemove) {
397402
try {
@@ -402,6 +407,7 @@ public void removeObsoleteServices() throws InterruptedException, ServiceUpdateE
402407
DeploymentErrorCodeUtils.classifyComponentError(service, kernel));
403408
}
404409
}
410+
405411
servicesToRemove.forEach(serviceName -> {
406412
Value removed = kernel.getContext().remove(serviceName);
407413
if (removed != null && !removed.isEmpty()) {

src/main/java/com/aws/greengrass/lifecyclemanager/GenericExternalService.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,68 @@ protected void shutdown() {
600600
}
601601
}
602602

603+
/**
604+
* Execute the uninstall lifecycle script for permanent component removal.
605+
* This method runs the uninstall script defined in the component recipe.
606+
* Uninstall execution blocks the calling thread but does not fail the deployment on error.
607+
*
608+
* <p>Executes the uninstall lifecycle script for this component.
609+
* This method is called when the component is being permanently removed
610+
* from the device (not during upgrades).
611+
*
612+
* <p>The uninstall script runs with environment variables providing context:
613+
* <ul>
614+
* <li>GREENGRASS_LIFECYCLE_EVENT - Set to "uninstall"</li>
615+
* <li>GREENGRASS_COMPONENT_NAME - The component name</li>
616+
* <li>GREENGRASS_COMPONENT_VERSION - The component version</li>
617+
* </ul>
618+
*
619+
* <p>If the script fails or times out, the error is logged but the component
620+
* removal proceeds. This ensures cleanup operations don't block component removal.
621+
*
622+
* <p>This method is called from {@link Lifecycle#serviceTerminatedMoveToDesiredState()}
623+
* after the component has completed its shutdown lifecycle and reached FINISHED state.
624+
*/
625+
@Override
626+
protected void uninstall() {
627+
try (LockScope ls = LockScope.lock(lock)) {
628+
long startTime = System.currentTimeMillis();
629+
logger.atInfo()
630+
.kv("componentName", getServiceName())
631+
.kv("componentVersion", Coerce.toString(getConfig().find(VERSION_CONFIG_KEY)))
632+
.log("Uninstall initiated");
633+
634+
try {
635+
RunResult result = run(Lifecycle.LIFECYCLE_UNINSTALL_NAMESPACE_TOPIC, null, lifecycleProcesses, false);
636+
if (result.getExec() != null) {
637+
result.getExec().setenv("GREENGRASS_LIFECYCLE_EVENT", "uninstall");
638+
result.getExec().setenv("GREENGRASS_COMPONENT_NAME", getServiceName());
639+
Topic versionTopic = getConfig().find(VERSION_CONFIG_KEY);
640+
if (versionTopic != null) {
641+
result.getExec().setenv("GREENGRASS_COMPONENT_VERSION", Coerce.toString(versionTopic));
642+
}
643+
if (result.getDoExec() != null) {
644+
result.getDoExec().apply();
645+
}
646+
}
647+
long duration = System.currentTimeMillis() - startTime;
648+
logger.atInfo()
649+
.setEventType("generic-service-uninstall")
650+
.kv("componentName", getServiceName())
651+
.kv("uninstallSuccess", true)
652+
.kv("uninstallDurationMs", duration)
653+
.log();
654+
} catch (InterruptedException ex) {
655+
long duration = System.currentTimeMillis() - startTime;
656+
logger.atWarn("generic-service-uninstall-interrupted")
657+
.kv("componentName", getServiceName())
658+
.kv("uninstallDurationMs", duration)
659+
.log("Thread interrupted while uninstalling service");
660+
Thread.currentThread().interrupt();
661+
}
662+
}
663+
}
664+
603665
/**
604666
* Shutdown a service without running the shutdown script.
605667
* Including stop processes and clean up resources.

src/main/java/com/aws/greengrass/lifecyclemanager/GreengrassService.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,13 @@ public final void requestStop() {
271271
lifecycle.requestStop();
272272
}
273273

274+
/**
275+
* Request uninstall for permanent component removal.
276+
*/
277+
public final void requestUninstall() {
278+
lifecycle.requestUninstall();
279+
}
280+
274281
/**
275282
* Custom handler to handle error.
276283
*
@@ -430,6 +437,14 @@ protected void shutdown() throws InterruptedException {
430437
}
431438
}
432439

440+
/**
441+
* Called when the component is being permanently removed from the system.
442+
* This method is invoked during component removal when there is no future version.
443+
* Default implementation does nothing; subclasses can override to perform cleanup.
444+
*/
445+
protected void uninstall() {
446+
}
447+
433448
/**
434449
* Moves the service to finished state and shuts down lifecycle thread.
435450
*

0 commit comments

Comments
 (0)