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
57 changes: 33 additions & 24 deletions ee/agent/reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
"github.com/kolide/launcher/pkg/traces"
)

type dbResetRecord struct {
type DBResetRecord struct {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not totally sure why this is exported

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh I might be able to un-export this one too after moving to using agent.GetResetRecords, will give that a try

NodeKey string `json:"node_key"`
PubKeys [][]byte `json:"pub_keys"`
Serial string `json:"serial"`
Expand All @@ -35,7 +35,7 @@ var (
hostDataKeyHardwareUuid = []byte("hardware_uuid")
hostDataKeyMunemo = []byte("munemo")

hostDataKeyResetRecords = []byte("reset_records")
HostDataKeyResetRecords = []byte("reset_records")
)

const (
Expand Down Expand Up @@ -79,24 +79,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 := ResetDatabase(ctx, k, resetReasonNewHardwareOrEnrollmentDetected); err != nil {
k.Slogger().Log(ctx, slog.LevelError, "failed to reset database", "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)
}

*/

// Cache hardware and rollout data for future checks
Expand Down Expand Up @@ -286,7 +274,7 @@ func prepareDatabaseResetRecords(ctx context.Context, k types.Knapsack, resetRea
k.Slogger().Log(ctx, slog.LevelWarn, "could not get tombstone id from store", "err", err)
}

dataToStore := dbResetRecord{
dataToStore := DBResetRecord{
NodeKey: string(nodeKey),
PubKeys: [][]byte{localPubKey},
Serial: string(serial),
Expand All @@ -299,15 +287,15 @@ func prepareDatabaseResetRecords(ctx context.Context, k types.Knapsack, resetRea
ResetReason: resetReason,
}

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

var hostDataCollection []dbResetRecord
var hostDataCollection []DBResetRecord
if len(previousHostData) == 0 {
// No previous database resets
hostDataCollection = []dbResetRecord{dataToStore}
hostDataCollection = []DBResetRecord{dataToStore}
} else {
if err := json.Unmarshal(previousHostData, &hostDataCollection); err != nil {
return nil, fmt.Errorf("unmarshalling previous host data: %w", err)
Expand Down Expand Up @@ -344,9 +332,30 @@ func getLocalPubKey(k types.Knapsack) ([]byte, error) { // nolint:unused
return pubKeyBytes, nil
}

// WipeDatabase iterates over all stores in the database, deleting all keys from
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)
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
}

// 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
12 changes: 6 additions & 6 deletions ee/agent/reset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,12 +359,12 @@ func TestDetectAndRemediateHardwareChange(t *testing.T) {
// Confirm backup occurred, if database got wiped
if tt.expectDatabaseWipe {
// Confirm the old_host_data key exists in the data store
dataRaw, err := testHostDataStore.Get(hostDataKeyResetRecords)
dataRaw, err := testHostDataStore.Get(HostDataKeyResetRecords)
require.NoError(t, err, "could not get old host data from test store")
require.NotNil(t, dataRaw, "old host data not set in store")

// Confirm that it contains reasonable data
var d []dbResetRecord
var d []DBResetRecord
require.NoError(t, json.Unmarshal(dataRaw, &d), "old host data in unexpected format")

// We should only have one backup
Expand Down Expand Up @@ -496,13 +496,13 @@ func TestDetectAndRemediateHardwareChange_SavesDataOverMultipleResets(t *testing
DetectAndRemediateHardwareChange(context.TODO(), mockKnapsack)

// Confirm the old_host_data key exists in the data store
dataRaw, err := testHostDataStore.Get(hostDataKeyResetRecords)
dataRaw, err := testHostDataStore.Get(HostDataKeyResetRecords)
require.NoError(t, err, "could not get old host data from test store")
require.NotNil(t, dataRaw, "old host data not set in store")

// Confirm that it contains reasonable data: we should have one backup
// with the first munemo in it
var d []dbResetRecord
var d []DBResetRecord
require.NoError(t, json.Unmarshal(dataRaw, &d), "old host data in unexpected format")
require.Equal(t, 1, len(d), "unexpected number of backups")
require.Equal(t, string(firstMunemoValue), d[0].Munemo, "munemo does not match")
Expand Down Expand Up @@ -530,14 +530,14 @@ func TestDetectAndRemediateHardwareChange_SavesDataOverMultipleResets(t *testing
DetectAndRemediateHardwareChange(context.TODO(), mockKnapsack)

// Confirm the old_host_data key exists in the data store
newDataRaw, err := testHostDataStore.Get(hostDataKeyResetRecords)
newDataRaw, err := testHostDataStore.Get(HostDataKeyResetRecords)
require.NoError(t, err, "could not get old host data from test store")
require.NotNil(t, dataRaw, "old host data not set in store")

// Confirm that it contains reasonable data: we should have two backups
// now -- the first should have the first munemo in it, and the second
// should have the second.
var dNew []dbResetRecord
var dNew []DBResetRecord
require.NoError(t, json.Unmarshal(newDataRaw, &dNew), "old host data in unexpected format")
require.Equal(t, 2, len(dNew), "unexpected number of backups")
require.Equal(t, string(firstMunemoValue), dNew[0].Munemo, "first backup munemo does not match")
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}, doctorSupported | flareSupported},
zackattack01 marked this conversation as resolved.
Show resolved Hide resolved
}

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

import (
"context"
"encoding/json"
"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)
if hc.k.PersistentHostDataStore() == nil {
hc.status = Informational
hc.summary = "Unable to access uninstall history"
return nil
}

resetRecordsRaw, err := hc.k.PersistentHostDataStore().Get(agent.HostDataKeyResetRecords)
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider moving this to something like agent.GetResetRecords(ctx context.Context, k types.Knapsack) maybe.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will go ahead and do that, it should clean this all up a bit. thank you!

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

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

if err := json.Unmarshal(resetRecordsRaw, &resetRecords); err != nil {
hc.status = Erroring
hc.summary = "Unable to unmarshal previous host data from store"
hc.data["error"] = err.Error()
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 = Passing
zackattack01 marked this conversation as resolved.
Show resolved Hide resolved
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
73 changes: 45 additions & 28 deletions ee/uninstall/uninstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ package uninstall

import (
"context"
"encoding/json"
"fmt"
"os"
"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 +42,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 +91,22 @@ 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)
resetRecordsRaw, err := testHostDataStore.Get(agent.HostDataKeyResetRecords)
require.NoError(t, err, "could not get reset records from test store")
var resetRecords []agent.DBResetRecord
require.Greater(t, len(resetRecordsRaw), 0, "did not expect reset records to be empty")
err = json.Unmarshal(resetRecordsRaw, &resetRecords)
require.NoError(t, err, "expected to be able to unmarshal reset records")
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")
})
}
}