Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add database reset history to uninstall and add uninstall history checkup #1639

Merged
merged 9 commits into from
Mar 7, 2024
90 changes: 58 additions & 32 deletions ee/agent/reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,19 @@ var (
hostDataKeySerial = []byte("serial")
hostDataKeyHardwareUuid = []byte("hardware_uuid")
hostDataKeyMunemo = []byte("munemo")

hostDataKeyResetRecords = []byte("reset_records")
)

const (
resetReasonNewHardwareOrEnrollmentDetected = "launcher detected new hardware or enrollment"
)

type UninitializedStorageError struct{}

func (use UninitializedStorageError) Error() string {
return "storage is uninitialized in knapsack"
}

// DetectAndRemediateHardwareChange checks to see if the hardware this installation is running on
// has changed, by checking current hardware- and enrollment- identifying information against
// stored data in the HostDataStore. If the hardware- or enrollment-identifying information
Expand Down Expand Up @@ -79,24 +84,12 @@ func DetectAndRemediateHardwareChange(ctx context.Context, k types.Knapsack) {
)

// In the future, we can proceed with backing up and resetting the database.
// For now, we are only logging that we detected the change.
// For now, we are only logging that we detected the change until we have a dependable
// hardware change detection method - see issue here https://github.com/kolide/launcher/issues/1346
/*
backup, err := prepareDatabaseResetRecords(ctx, k, resetReasonNewHardwareOrEnrollmentDetected)
if err != nil {
k.Slogger().Log(ctx, slog.LevelWarn, "could not prepare db reset records", "err", err)
}

if err := wipeDatabase(ctx, k); err != nil {
k.Slogger().Log(ctx, slog.LevelError, "could not wipe database", "err", err)
return
}


// Store the backup data
if err := k.PersistentHostDataStore().Set(hostDataKeyResetRecords, backup); err != nil {
k.Slogger().Log(ctx, slog.LevelWarn, "could not store db reset records", "err", err)
if err := ResetDatabase(ctx, k, resetReasonNewHardwareOrEnrollmentDetected); err != nil {
k.Slogger().Log(ctx, slog.LevelError, "failed to reset database", "err", err)
}

*/

// Cache hardware and rollout data for future checks
Expand All @@ -112,6 +105,49 @@ func DetectAndRemediateHardwareChange(ctx context.Context, k types.Knapsack) {
}
}

func GetResetRecords(ctx context.Context, k types.Knapsack) ([]dbResetRecord, error) {
resetRecords := make([]dbResetRecord, 0)
if k.PersistentHostDataStore() == nil {
return resetRecords, UninitializedStorageError{}
}

resetRecordsRaw, err := k.PersistentHostDataStore().Get(hostDataKeyResetRecords)
if err != nil {
return resetRecords, err
}

if len(resetRecordsRaw) == 0 {
return resetRecords, nil
}

if err := json.Unmarshal(resetRecordsRaw, &resetRecords); err != nil {
return resetRecords, err
}

return resetRecords, nil
}

func ResetDatabase(ctx context.Context, k types.Knapsack, resetReason string) error {
backup, err := prepareDatabaseResetRecords(ctx, k, resetReason)
if err != nil {
k.Slogger().Log(ctx, slog.LevelWarn, "could not prepare db reset records", "err", err)
zackattack01 marked this conversation as resolved.
Show resolved Hide resolved
return err
}

if err := wipeDatabase(ctx, k); err != nil {
k.Slogger().Log(ctx, slog.LevelError, "could not wipe database", "err", err)
return err
}

// Store the backup data
if err := k.PersistentHostDataStore().Set(hostDataKeyResetRecords, backup); err != nil {
k.Slogger().Log(ctx, slog.LevelWarn, "could not store db reset records", "err", err)
return err
}

return nil
}

// currentSerialAndHardwareUUID queries osquery for the required information.
func currentSerialAndHardwareUUID(ctx context.Context, k types.Knapsack) (string, string, error) {
osqPath := k.LatestOsquerydPath(ctx)
Expand Down Expand Up @@ -299,23 +335,13 @@ func prepareDatabaseResetRecords(ctx context.Context, k types.Knapsack, resetRea
ResetReason: resetReason,
}

previousHostData, err := k.PersistentHostDataStore().Get(hostDataKeyResetRecords)
previousHostData, err := GetResetRecords(ctx, k)
if err != nil {
return nil, fmt.Errorf("getting previous host data from store: %w", err)
}

var hostDataCollection []dbResetRecord
if len(previousHostData) == 0 {
// No previous database resets
hostDataCollection = []dbResetRecord{dataToStore}
} else {
if err := json.Unmarshal(previousHostData, &hostDataCollection); err != nil {
return nil, fmt.Errorf("unmarshalling previous host data: %w", err)
}
hostDataCollection = append(hostDataCollection, dataToStore)
}

hostDataCollectionRaw, err := json.Marshal(hostDataCollection)
previousHostData = append(previousHostData, dataToStore)
hostDataCollectionRaw, err := json.Marshal(previousHostData)
if err != nil {
return nil, fmt.Errorf("marshalling host data for storage: %w", err)
}
Expand Down Expand Up @@ -344,9 +370,9 @@ func getLocalPubKey(k types.Knapsack) ([]byte, error) { // nolint:unused
return pubKeyBytes, nil
}

// WipeDatabase iterates over all stores in the database, deleting all keys from
// wipeDatabase iterates over all stores in the database, deleting all keys from
// each one.
func WipeDatabase(ctx context.Context, k types.Knapsack) error {
func wipeDatabase(ctx context.Context, k types.Knapsack) error {
for storeName, store := range k.Stores() {
if err := store.DeleteAll(); err != nil {
return fmt.Errorf("deleting keys in store %s: %w", storeName, err)
Expand Down
1 change: 1 addition & 0 deletions ee/debug/checkups/checkups.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func checkupsFor(k types.Knapsack, target targetBits) []checkupInt {
{&serverDataCheckup{k: k}, doctorSupported | flareSupported | logSupported},
{&osqDataCollector{k: k}, doctorSupported | flareSupported},
{&osqRestartCheckup{k: k}, doctorSupported | flareSupported},
{&uninstallHistoryCheckup{k: k}, flareSupported},
}

checkupsToRun := make([]checkupInt, 0)
Expand Down
68 changes: 68 additions & 0 deletions ee/debug/checkups/uninstall_history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package checkups

import (
"context"
"errors"
"io"
"time"

"github.com/kolide/launcher/ee/agent"
"github.com/kolide/launcher/ee/agent/types"
)

type (
uninstallHistoryCheckup struct {
k types.Knapsack
status Status
summary string
data map[string]any
}
)

func (hc *uninstallHistoryCheckup) Data() any { return hc.data }
func (hc *uninstallHistoryCheckup) ExtraFileName() string { return "" }
func (hc *uninstallHistoryCheckup) Name() string { return "Uninstall History" }
func (hc *uninstallHistoryCheckup) Status() Status { return hc.status }
func (hc *uninstallHistoryCheckup) Summary() string { return hc.summary }

func (hc *uninstallHistoryCheckup) Run(ctx context.Context, extraFH io.Writer) error {
hc.data = make(map[string]any)
resetRecords, err := agent.GetResetRecords(ctx, hc.k)
if err != nil && errors.Is(err, agent.UninitializedStorageError{}) {
hc.status = Informational
hc.summary = "Unable to access uninstall history"
return nil
}

if err != nil {
hc.status = Erroring
hc.summary = "Unable to gather previous host data from store"
hc.data["error"] = err.Error()
return nil
}

if len(resetRecords) == 0 {
hc.status = Informational
hc.summary = "No installation history exists for this device"
return nil
}

for _, uninstallRecord := range resetRecords {
resetTimeKey := time.Unix(uninstallRecord.ResetTimestamp, 0)
hc.data[resetTimeKey.Format(time.RFC3339)] = map[string]any{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine, but feels like a bit of overkill. The time sorted array was probably fine

"serial": uninstallRecord.Serial,
"hardware_uuid": uninstallRecord.HardwareUUID,
"munemo": uninstallRecord.Munemo,
"device_id": uninstallRecord.DeviceID,
"remote_ip": uninstallRecord.RemoteIP,
"tombstone_id": uninstallRecord.TombstoneID,
"reset_timestamp": resetTimeKey,
"reset_reason": uninstallRecord.ResetReason,
}
}

hc.status = Informational
hc.summary = "Successfully collected uninstallation history"

return nil
}
8 changes: 6 additions & 2 deletions ee/uninstall/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import (
"github.com/kolide/launcher/ee/agent/types"
)

const (
resetReasonUninstallRequested = "remote uninstall requested"
)

// Uninstall just removes the enroll secret file and wipes the database.
// Logs errors, but does not return them, because we want to try each step independently.
// If exitOnCompletion is true, it will also disable launcher autostart and exit.
Expand All @@ -23,9 +27,9 @@ func Uninstall(ctx context.Context, k types.Knapsack, exitOnCompletion bool) {
)
}

if err := agent.WipeDatabase(ctx, k); err != nil {
if err := agent.ResetDatabase(ctx, k, resetReasonUninstallRequested); err != nil {
slogger.Log(ctx, slog.LevelError,
"wiping database",
"resetting database",
"err", err,
)
}
Expand Down
68 changes: 40 additions & 28 deletions ee/uninstall/uninstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"path/filepath"
"testing"

"github.com/kolide/kit/ulid"
"github.com/kolide/launcher/ee/agent"
"github.com/kolide/launcher/ee/agent/storage"
storageci "github.com/kolide/launcher/ee/agent/storage/ci"
"github.com/kolide/launcher/ee/agent/types"
Expand Down Expand Up @@ -41,46 +41,47 @@ func TestUninstall(t *testing.T) {
_, err = os.Stat(enrollSecretPath)
require.NoError(t, err)

// create 3 stores with 3 items each
stores := map[storage.Store]types.KVStore{}
for i := 0; i < 3; i++ {
store, err := storageci.NewStore(t, multislogger.NewNopLogger(), ulid.New())
require.NoError(t, err)
k := mocks.NewKnapsack(t)
k.On("EnrollSecretPath").Return(enrollSecretPath)
k.On("Slogger").Return(multislogger.NewNopLogger())
testConfigStore, err := storageci.NewStore(t, multislogger.NewNopLogger(), storage.ConfigStore.String())
require.NoError(t, err, "could not create test config store")
k.On("ConfigStore").Return(testConfigStore)
testHostDataStore, err := storageci.NewStore(t, multislogger.NewNopLogger(), storage.PersistentHostDataStore.String())
require.NoError(t, err, "could not create test host data store")
k.On("PersistentHostDataStore").Return(testHostDataStore)
testServerProvidedDataStore, err := storageci.NewStore(t, multislogger.NewNopLogger(), storage.ServerProvidedDataStore.String())
require.NoError(t, err, "could not create test server provided data store")
k.On("ServerProvidedDataStore").Return(testServerProvidedDataStore)
stores := map[storage.Store]types.KVStore{
storage.PersistentHostDataStore: testHostDataStore,
storage.ConfigStore: testConfigStore,
storage.ServerProvidedDataStore: testServerProvidedDataStore,
}
k.On("Stores").Return(stores)
testSerial := []byte("C999999999")
testHardwareUUID := []byte("99999999-9999-9999-9999-999999999999")

// seed the test storage with known serial and hardware_uuids to test against the reset records later
require.NoError(t, testHostDataStore.Set([]byte("serial"), testSerial), "could not set serial in test store")
require.NoError(t, testHostDataStore.Set([]byte("hardware_uuid"), testHardwareUUID), "could not set hardware uuid in test store")
// additionally seed all stores with some data to ensure we are clearing all values later
for _, store := range stores {
for j := 0; j < 3; j++ {
require.NoError(t, store.Set([]byte(fmt.Sprint(j)), []byte(fmt.Sprint(j))))
}

require.NoError(t, err)
stores[storage.Store(fmt.Sprint(i))] = store
}

// sanity check that we have 3 stores with 3 items each
itemsExpected := 9
itemsFound := 0
for _, store := range stores {
store.ForEach(
func(k, v []byte) error {
itemsFound++
return nil
},
)
}
require.Equal(t, itemsExpected, itemsFound)

k := mocks.NewKnapsack(t)
k.On("Stores").Return(stores)
k.On("EnrollSecretPath").Return(enrollSecretPath)
k.On("Slogger").Return(multislogger.NewNopLogger())

Uninstall(context.TODO(), k, false)

// check that file was deleted
_, err = os.Stat(enrollSecretPath)
require.True(t, os.IsNotExist(err))

// check that all stores are empty
itemsFound = 0
// check that all stores are empty except for the uninstallation history
itemsFound := 0
for _, store := range stores {
store.ForEach(
func(k, v []byte) error {
Expand All @@ -89,7 +90,18 @@ func TestUninstall(t *testing.T) {
},
)
}
require.Equal(t, 0, itemsFound)

// the expectation of 1 here is coming from the single remaining reset_records key
// see agent.ResetDatabase for additional context
require.Equal(t, 1, itemsFound)
resetRecords, err := agent.GetResetRecords(context.TODO(), k)
require.NoError(t, err, "could not get reset records from test store")
require.Equal(t, 1, len(resetRecords), "expected reset records to contain exactly 1 uninstallation record")
// now check the individual bits we want to ensure are migrated to the reset record
resetRecord := resetRecords[0]
require.Equal(t, resetReasonUninstallRequested, resetRecord.ResetReason, "expected reset record to indicate the uninstall requested")
require.Equal(t, string(testSerial), resetRecord.Serial, "expected reset record to indicate the serial number from the original installation")
require.Equal(t, string(testHardwareUUID), resetRecord.HardwareUUID, "expected reset record to indicate the hardware UUID from the original installation")
})
}
}