Skip to content

Commit fe9ca4e

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

File tree

13 files changed

+631
-11
lines changed

13 files changed

+631
-11
lines changed

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,97 @@ 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 finishedLatch = new CountDownLatch(1);
894+
CountDownLatch uninstalledLatch = new CountDownLatch(1);
895+
896+
kernel.getContext().addGlobalStateChangeListener((service, oldState, newState) -> {
897+
if (service.getName().equals("UninstallTestComponent")) {
898+
if (newState.equals(State.FINISHED)) {
899+
finishedLatch.countDown();
900+
} else if (newState.equals(State.UNINSTALLED)) {
901+
uninstalledLatch.countDown();
902+
}
903+
}
904+
});
905+
906+
kernel.launch();
907+
assertTrue(finishedLatch.await(30, TimeUnit.SECONDS), "component should reach FINISHED");
908+
909+
GenericExternalService component = (GenericExternalService) kernel.locate("UninstallTestComponent");
910+
component.requestUninstall();
911+
912+
assertTrue(uninstalledLatch.await(30, TimeUnit.SECONDS), "component should reach UNINSTALLED");
913+
assertThat(component.getState(), is(State.UNINSTALLED));
914+
}
915+
916+
// AC2: Uninstall script failure doesn't fail deployment
917+
@Test
918+
void GIVEN_component_with_failing_uninstall_script_WHEN_uninstall_THEN_component_still_uninstalled() throws Exception {
919+
ConfigPlatformResolver.initKernelWithMultiPlatformConfig(kernel,
920+
getClass().getResource("uninstall_failure_config.yaml"));
921+
922+
CountDownLatch finishedLatch = new CountDownLatch(1);
923+
CountDownLatch uninstalledLatch = new CountDownLatch(1);
924+
925+
kernel.getContext().addGlobalStateChangeListener((service, oldState, newState) -> {
926+
if (service.getName().equals("FailingUninstallComponent")) {
927+
if (newState.equals(State.FINISHED)) {
928+
finishedLatch.countDown();
929+
} else if (newState.equals(State.UNINSTALLED)) {
930+
uninstalledLatch.countDown();
931+
}
932+
}
933+
});
934+
935+
kernel.launch();
936+
assertTrue(finishedLatch.await(30, TimeUnit.SECONDS), "component should reach FINISHED");
937+
938+
GenericExternalService component = (GenericExternalService) kernel.locate("FailingUninstallComponent");
939+
component.requestUninstall();
940+
941+
assertTrue(uninstalledLatch.await(30, TimeUnit.SECONDS), "component should reach UNINSTALLED despite script failure");
942+
assertThat(component.getState(), is(State.UNINSTALLED));
943+
}
944+
945+
// AC7: State transitions
946+
@Test
947+
void GIVEN_component_WHEN_uninstall_requested_THEN_goes_through_UNINSTALLING_to_UNINSTALLED_states() throws Exception {
948+
ConfigPlatformResolver.initKernelWithMultiPlatformConfig(kernel,
949+
getClass().getResource("uninstall_state_transition_config.yaml"));
950+
951+
List<State> stateTransitions = new CopyOnWriteArrayList<>();
952+
CountDownLatch finishedLatch = new CountDownLatch(1);
953+
CountDownLatch uninstalledLatch = new CountDownLatch(1);
954+
955+
kernel.getContext().addGlobalStateChangeListener((service, oldState, newState) -> {
956+
if (service.getName().equals("StateTransitionComponent")) {
957+
stateTransitions.add(newState);
958+
if (newState.equals(State.FINISHED)) {
959+
finishedLatch.countDown();
960+
} else if (newState.equals(State.UNINSTALLED)) {
961+
uninstalledLatch.countDown();
962+
}
963+
}
964+
});
965+
966+
kernel.launch();
967+
assertTrue(finishedLatch.await(30, TimeUnit.SECONDS), "component should reach FINISHED");
968+
969+
GenericExternalService component = (GenericExternalService) kernel.locate("StateTransitionComponent");
970+
component.requestUninstall();
971+
972+
assertTrue(uninstalledLatch.await(30, TimeUnit.SECONDS), "component should reach UNINSTALLED");
973+
974+
assertTrue(stateTransitions.contains(State.UNINSTALLING), "Should transition through UNINSTALLING");
975+
assertTrue(stateTransitions.contains(State.UNINSTALLED), "Should reach UNINSTALLED");
976+
}
977+
887978
private boolean isCgroupV2Supported() {
888979
return Files.exists(Paths.get("/sys/fs/cgroup/cgroup.controllers"));
889980
}
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+
version: 1.0.0
11+
lifecycle:
12+
run: echo "Component running"
13+
uninstall: |
14+
echo "Uninstall script starting"
15+
echo "Simulating uninstall failure"
16+
exit 1
17+
18+
main:
19+
lifecycle:
20+
run: echo "Running main"
21+
dependencies:
22+
- FailingUninstallComponent
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+
version: 1.0.0
11+
lifecycle:
12+
run: echo "Component running"
13+
uninstall: |
14+
echo "Uninstall script executing"
15+
sleep 1
16+
echo "Uninstall completed"
17+
18+
main:
19+
lifecycle:
20+
run: echo "Running main"
21+
dependencies:
22+
- StateTransitionComponent
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
services:
3+
aws.greengrass.Nucleus:
4+
configuration:
5+
runWithDefault:
6+
posixUser: nobody
7+
windowsUser: integ-tester
8+
9+
UninstallTestComponent:
10+
version: 1.0.0
11+
lifecycle:
12+
run: echo "Component running"
13+
uninstall: |
14+
echo "Uninstall script executing"
15+
echo "Component uninstalled successfully"
16+
17+
main:
18+
lifecycle:
19+
run: echo "Running main"
20+
dependencies:
21+
- UninstallTestComponent
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
services:
3+
aws.greengrass.Nucleus:
4+
configuration:
5+
runWithDefault:
6+
posixUser: nobody
7+
windowsUser: integ-tester
8+
9+
TimeoutUninstallComponent:
10+
version: 1.0.0
11+
lifecycle:
12+
run: echo "Component running"
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+
20+
main:
21+
lifecycle:
22+
run: echo "Running main"
23+
dependencies:
24+
- TimeoutUninstallComponent

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ public enum ComponentStatusCode {
5151
SHUTDOWN_ERROR("An error occurred while shutting down the component.",
5252
"The shutdown script exited with code %s."),
5353
SHUTDOWN_TIMEOUT("Shutdown script didn't finish within the timeout period. Increase the timeout to give it more "
54-
+ "time to run or check your code.");
54+
+ "time to run or check your code."),
55+
56+
UNINSTALL_TIMEOUT("uninstall script didn't finish within the timeout period. Increase the timeout to give it "
57+
+ "more time to run or check your code."),
58+
UNINSTALL_ERROR("An error occurred while uninstalling the component.");
5559

5660
@Getter
5761
private String description;
@@ -165,6 +169,8 @@ private static ComponentStatusCode getDefaultErrorCodeFrom(State previousState)
165169
return RUN_ERROR;
166170
case STOPPING:
167171
return SHUTDOWN_ERROR;
172+
case UNINSTALLING:
173+
return UNINSTALL_ERROR;
168174
default:
169175
return NONE;
170176
}

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: 5 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 {

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public class GenericExternalService extends GreengrassService {
6363
private static final String SKIP_COMMAND_REGEX = "(exists|onpath) +(.+)";
6464
private static final Pattern SKIPCMD = Pattern.compile(SKIP_COMMAND_REGEX);
6565
private static final String CONFIG_NODE = "configNode";
66+
public static final String COMPONENT_VERSION_ENV_NAME = "GREENGRASS_COMPONENT_VERSION";
6667
// Logger which write to a file for just this service
6768
protected final Logger separateLogger;
6869
protected final Platform platform;
@@ -600,6 +601,34 @@ protected void shutdown() {
600601
}
601602
}
602603

604+
/**
605+
* Execute the uninstall lifecycle script for permanent component removal.
606+
*/
607+
@Override
608+
protected void uninstall() {
609+
try (LockScope ls = LockScope.lock(lock)) {
610+
logger.atInfo().log("Shutdown initiated");
611+
612+
try {
613+
RunResult result = run(Lifecycle.LIFECYCLE_UNINSTALL_NAMESPACE_TOPIC, null, lifecycleProcesses, false);
614+
if (result.getExec() != null) {
615+
Topic versionTopic = getConfig().find(VERSION_CONFIG_KEY);
616+
if (versionTopic != null) {
617+
result.getExec().setenv(COMPONENT_VERSION_ENV_NAME, Coerce.toString(versionTopic));
618+
}
619+
if (result.getDoExec() != null) {
620+
result.getDoExec().apply();
621+
}
622+
}
623+
} catch (InterruptedException ex) {
624+
logger.atWarn("generic-service-uninstall-interrupted")
625+
.kv("componentName:", getServiceName())
626+
.log("Thread interrupted while uninstalling service");
627+
Thread.currentThread().interrupt();
628+
}
629+
}
630+
}
631+
603632
/**
604633
* Shutdown a service without running the shutdown script.
605634
* 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() throws InterruptedException {
446+
}
447+
433448
/**
434449
* Moves the service to finished state and shuts down lifecycle thread.
435450
*

0 commit comments

Comments
 (0)