services/device/interna/impl: tidy installations

This change continues adding a tidying a feature to the device manager
where it will delete Uninstalled installations not referenced by an
existing instance.

Change-Id: I4404d5758cbe814eda6b0ba4de3734fe120697c1
diff --git a/services/device/internal/impl/applife/app_life_test.go b/services/device/internal/impl/applife/app_life_test.go
index 60b1e78..cfd0002 100644
--- a/services/device/internal/impl/applife/app_life_test.go
+++ b/services/device/internal/impl/applife/app_life_test.go
@@ -358,12 +358,13 @@
 		t.Fatalf("Pid of hanging app (%d) has not exited after Stop() call", hangingPid)
 	}
 
-	// Record all instances.
-	shouldKeep := determineShouldKeep(t, root)
+	shouldKeepInstances := determineShouldKeep(t, root, filepath.Join(root, "app*", "installation*", "instances", "instance*"), "Deleted")
+	shouldKeepInstallations := addBackLinks(t, root, determineShouldKeep(t, root, filepath.Join(root, "app*", "installation*"), "Uninstalled"))
 	if err := utiltest.DeviceStub("dm").TidyNow(ctx); err != nil {
 		t.Fatalf("TidyNow failed: %v", err)
 	}
-	validateTidying(t, root, shouldKeep)
+	validateTidying(t, root, filepath.Join(root, "app*", "installation*", "instances", "instance*"), shouldKeepInstances)
+	validateTidying(t, root, filepath.Join(root, "app*", "installation*"), shouldKeepInstallations)
 
 	// Cleanly shut down the device manager.
 	defer utiltest.VerifyNoRunningProcesses(t)
@@ -372,15 +373,15 @@
 	dmh.ExpectEOF()
 }
 
-func determineShouldKeep(t *testing.T, root string) map[string]bool {
-	paths, err := filepath.Glob(filepath.Join(root, "app*", "installation*", "instances", "instance*"))
+func determineShouldKeep(t *testing.T, root, globpath, state string) map[string]bool {
+	paths, err := filepath.Glob(globpath)
 	if err != nil {
 		t.Errorf("determineShouldKeep %v", err)
 	}
 
 	shouldKeep := make(map[string]bool)
 	for _, idir := range paths {
-		p := filepath.Join(idir, "Deleted")
+		p := filepath.Join(idir, state)
 		_, err := os.Stat(p)
 		if os.IsNotExist(err) {
 			shouldKeep[idir] = true
@@ -391,10 +392,31 @@
 		}
 	}
 	return shouldKeep
+
 }
 
-func validateTidying(t *testing.T, root string, shouldKeep map[string]bool) {
-	paths, err := filepath.Glob(filepath.Join(root, "app*", "installation*", "instances", "instance*"))
+func addBackLinks(t *testing.T, root string, installationShouldKeep map[string]bool) map[string]bool {
+	paths, err := filepath.Glob(filepath.Join(root, "app*", "installation*", "instances", "instance*", "installation"))
+	if err != nil {
+		t.Errorf("addBackLinks %v", err)
+	}
+
+	for _, idir := range paths {
+		pth, err := os.Readlink(idir)
+		if err != nil {
+			t.Errorf("addBackLinks %v", err)
+			continue
+		}
+		if _, ok := installationShouldKeep[pth]; ok {
+			// An instance symlinks to this pth so must be kept.
+			installationShouldKeep[pth] = true
+		}
+	}
+	return installationShouldKeep
+}
+
+func validateTidying(t *testing.T, root, globpath string, shouldKeep map[string]bool) {
+	paths, err := filepath.Glob(globpath)
 	if err != nil {
 		t.Errorf("validateTidying %v", err)
 	}
@@ -402,7 +424,7 @@
 	// TidyUp adds nothing: pth should be a subset of shouldKeep.
 	for _, pth := range paths {
 		if _, ok := shouldKeep[pth]; !ok {
-			t.Errorf("TidyUp wrongly added path: %s", pth)
+			t.Errorf("TidyUp (%s) wrongly added path: %s", globpath, pth)
 			return
 		}
 	}
@@ -410,7 +432,7 @@
 	// Tidy should not leave unkept instances: shouldKeep ^ pth should be entirely true.
 	for _, pth := range paths {
 		if !shouldKeep[pth] {
-			t.Errorf("TidyUp failed to delete: %s", pth)
+			t.Errorf("TidyUp (%s) failed to delete: %s", globpath, pth)
 			return
 		}
 	}
@@ -419,7 +441,7 @@
 	for k, v := range shouldKeep {
 		if v {
 			if _, err := os.Stat(k); os.IsNotExist(err) {
-				t.Errorf("TidyUp deleted an instance it shouldn't have: %s", k)
+				t.Errorf("TidyUp (%s) deleted an instance it shouldn't have: %s", globpath, k)
 			}
 		}
 	}
diff --git a/services/device/internal/impl/device_service.go b/services/device/internal/impl/device_service.go
index 9cfcc6c..97f4e51 100644
--- a/services/device/internal/impl/device_service.go
+++ b/services/device/internal/impl/device_service.go
@@ -679,7 +679,11 @@
 
 // tidyHarness runs device manager cleanup operations
 func (s *deviceService) tidyHarness(ctx *context.T) error {
-	return pruneDeletedInstances(ctx, s.config.Root)
+	if err := pruneDeletedInstances(ctx, s.config.Root); err != nil {
+		return err
+	}
+
+	return pruneUninstalledInstallations(ctx, s.config.Root)
 }
 
 func (s *deviceService) TidyNow(ctx *context.T, call rpc.ServerCall) error {
diff --git a/services/device/internal/impl/tidyup.go b/services/device/internal/impl/tidyup.go
index e51509c..6b1ad1d 100644
--- a/services/device/internal/impl/tidyup.go
+++ b/services/device/internal/impl/tidyup.go
@@ -22,10 +22,8 @@
 // TidyAge defaults to 1 day. Settable for tests.
 var TidyOlderThan = time.Hour * 24
 
-// shouldDeleteInstance returns true if the tidying policy holds
-// that the instance should be deleted.
-func shouldDeleteInstance(idir string) (bool, error) {
-	fi, err := os.Stat(filepath.Join(idir, device.InstanceStateDeleted.String()))
+func shouldDelete(idir, suffix string) (bool, error) {
+	fi, err := os.Stat(filepath.Join(idir, suffix))
 	if err != nil {
 		return false, err
 	}
@@ -37,16 +35,29 @@
 	return false, nil
 }
 
+// shouldDeleteInstallation returns true if the tidying policy holds
+// for this installation.
+func shouldDeleteInstallation(idir string) (bool, error) {
+	return shouldDelete(idir, device.InstallationStateUninstalled.String())
+}
+
+// shouldDeleteInstance returns true if the tidying policy holds
+// that the instance should be deleted.
+func shouldDeleteInstance(idir string) (bool, error) {
+	return shouldDelete(idir, device.InstanceStateDeleted.String())
+}
+
+type pthError struct {
+	pth string
+	err error
+}
+
 func pruneDeletedInstances(ctx *context.T, root string) error {
 	paths, err := filepath.Glob(filepath.Join(root, "app*", "installation*", "instances", "instance*"))
 	if err != nil {
 		return err
 	}
 
-	type pthError struct {
-		pth string
-		err error
-	}
 	allerrors := make([]pthError, 0)
 
 	for _, pth := range paths {
@@ -71,7 +82,10 @@
 			}
 		}
 	}
+	return processErrors(ctx, allerrors)
+}
 
+func processErrors(ctx *context.T, allerrors []pthError) error {
 	if len(allerrors) > 0 {
 		errormessages := make([]string, 0, len(allerrors))
 		for _, ep := range allerrors {
@@ -81,3 +95,64 @@
 	}
 	return nil
 }
+
+func pruneUninstalledInstallations(ctx *context.T, root string) error {
+	// Read all the Uninstalled installations into a map.
+	installationPaths, err := filepath.Glob(filepath.Join(root, "app*", "installation*"))
+	if err != nil {
+		return err
+	}
+	pruneCandidates := make(map[string]struct{}, len(installationPaths))
+	for _, p := range installationPaths {
+		state, err := getInstallationState(p)
+		if err != nil {
+			return err
+		}
+
+		if state != device.InstallationStateUninstalled {
+			continue
+		}
+
+		pruneCandidates[p] = struct{}{}
+	}
+
+	instancePaths, err := filepath.Glob(filepath.Join(root, "app*", "installation*", "instances", "instance*", "installation"))
+	if err != nil {
+		return err
+	}
+
+	allerrors := make([]pthError, 0)
+
+	// Filter out installations that are still owned by an instance. Note
+	// that pruneUninstalledInstallations runs after
+	// pruneDeletedInstances so that freshly-pruned Instances will not
+	// retain the Installation.
+	for _, idir := range instancePaths {
+		installPath, err := os.Readlink(idir)
+		if err != nil {
+			allerrors = append(allerrors, pthError{idir, err})
+			continue
+		}
+
+		if _, ok := pruneCandidates[installPath]; ok {
+			delete(pruneCandidates, installPath)
+		}
+	}
+
+	// All remaining entries in pruneCandidates are not referenced by
+	// any instance.
+	for pth, _ := range pruneCandidates {
+		shouldDelete, err := shouldDeleteInstallation(pth)
+		if err != nil {
+			allerrors = append(allerrors, pthError{pth, err})
+			continue
+		}
+
+		if shouldDelete {
+			if err := suidHelper.deleteFileTree(pth, nil, nil); err != nil {
+				allerrors = append(allerrors, pthError{pth, err})
+			}
+		}
+	}
+	return processErrors(ctx, allerrors)
+}