From f9e9b4d32724e43904f4bec5e1d0368fb6c0a0f5 Mon Sep 17 00:00:00 2001 From: Chris Capurso Date: Mon, 14 Feb 2022 15:26:57 -0500 Subject: [PATCH] Add sys/version-history endpoint and associated command (#13766) * store version history as utc; add self-heal logic * add sys/version-history endpoint * change version history from GET to LIST, require auth * add "vault version-history" CLI command * add vault-version CLI error message for version string parsing * adding version-history API and CLI docs * add changelog entry * some version-history command fixes * remove extraneous cmd args * fix version-history command help text * specify in docs that endpoint was added in 1.10.0 Co-authored-by: Nick Cabatoff * enforce UTC within storeVersionTimestamp directly * fix improper use of %w in logger.Warn * remove extra err check and erroneous return from loadVersionTimestamps * add >= 1.10.0 warning to version-history cmd * move sys/version-history tests Co-authored-by: Nick Cabatoff --- changelog/13766.txt | 3 + command/commands.go | 5 + command/version_history.go | 130 ++++++++++++++++++ command/version_history_test.go | 111 +++++++++++++++ vault/core.go | 4 +- vault/core_util_common_test.go | 52 ------- vault/logical_system.go | 45 ++++++ vault/logical_system_integ_test.go | 66 +++++++++ vault/logical_system_paths.go | 12 ++ .../{core_util_common.go => version_store.go} | 90 ++++++++---- vault/version_store_test.go | 99 +++++++++++++ .../api-docs/system/version-history.mdx | 55 ++++++++ .../content/docs/commands/version-history.mdx | 31 +++++ website/data/api-docs-nav-data.json | 4 + website/data/docs-nav-data.json | 4 + website/redirects.next.js | 5 + 16 files changed, 638 insertions(+), 78 deletions(-) create mode 100644 changelog/13766.txt create mode 100644 command/version_history.go create mode 100644 command/version_history_test.go delete mode 100644 vault/core_util_common_test.go rename vault/{core_util_common.go => version_store.go} (53%) create mode 100644 vault/version_store_test.go create mode 100644 website/content/api-docs/system/version-history.mdx create mode 100644 website/content/docs/commands/version-history.mdx diff --git a/changelog/13766.txt b/changelog/13766.txt new file mode 100644 index 000000000..14913c1c9 --- /dev/null +++ b/changelog/13766.txt @@ -0,0 +1,3 @@ +```release-note:improvement +core: Add support to list version history via API at `sys/version-history` and via CLI with `vault version-history` +``` diff --git a/command/commands.go b/command/commands.go index ce6398356..cbf1b22a6 100644 --- a/command/commands.go +++ b/command/commands.go @@ -653,6 +653,11 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { BaseCommand: getBaseCommand(), }, nil }, + "version-history": func() (cli.Command, error) { + return &VersionHistoryCommand{ + BaseCommand: getBaseCommand(), + }, nil + }, "write": func() (cli.Command, error) { return &WriteCommand{ BaseCommand: getBaseCommand(), diff --git a/command/version_history.go b/command/version_history.go new file mode 100644 index 000000000..5c794bc0e --- /dev/null +++ b/command/version_history.go @@ -0,0 +1,130 @@ +package command + +import ( + "fmt" + "github.com/mitchellh/cli" + "github.com/posener/complete" + "github.com/ryanuber/columnize" + "strings" +) + +var ( + _ cli.Command = (*VersionHistoryCommand)(nil) + _ cli.CommandAutocomplete = (*VersionHistoryCommand)(nil) +) + +// VersionHistoryCommand is a Command implementation prints the version. +type VersionHistoryCommand struct { + *BaseCommand +} + +func (c *VersionHistoryCommand) Synopsis() string { + return "Prints the version history of the target Vault server" +} + +func (c *VersionHistoryCommand) Help() string { + helpText := ` +Usage: vault version-history + + Prints the version history of the target Vault server. + + Print the version history: + + $ vault version-history +` + c.Flags().Help() + return strings.TrimSpace(helpText) +} + +func (c *VersionHistoryCommand) Flags() *FlagSets { + return c.flagSet(FlagSetOutputFormat) +} + +func (c *VersionHistoryCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *VersionHistoryCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +const versionTrackingWarning = `Note: +Use of this command requires a server running Vault 1.10.0 or greater. +Version tracking was added in 1.9.0. Earlier versions have not been tracked. +` + +func (c *VersionHistoryCommand) Run(args []string) int { + f := c.Flags() + + if err := f.Parse(args); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + client, err := c.Client() + if err != nil { + c.UI.Error(err.Error()) + return 2 + } + + resp, err := client.Logical().List("sys/version-history") + if err != nil { + c.UI.Error(fmt.Sprintf("Error reading version history: %s", err)) + return 2 + } + + if resp == nil || resp.Data == nil { + c.UI.Error("Invalid response returned from Vault") + return 2 + } + + if c.flagFormat == "json" { + c.UI.Warn("") + c.UI.Warn(versionTrackingWarning) + c.UI.Warn("") + + return OutputData(c.UI, resp) + } + + var keyInfo map[string]interface{} + + keys, ok := extractListData(resp) + if !ok { + c.UI.Error("Expected keys in response to be an array") + return 2 + } + + keyInfo, ok = resp.Data["key_info"].(map[string]interface{}) + if !ok { + c.UI.Error("Expected key_info in response to be a map") + return 2 + } + + table := []string{"Version | Installation Time"} + columnConfig := columnize.DefaultConfig() + + for _, versionRaw := range keys { + version, ok := versionRaw.(string) + + if !ok { + c.UI.Error("Expected version to be string") + return 2 + } + + versionInfoRaw := keyInfo[version] + + versionInfo, ok := versionInfoRaw.(map[string]interface{}) + if !ok { + c.UI.Error(fmt.Sprintf("Expected version info for %q to be map", version)) + return 2 + } + + table = append(table, fmt.Sprintf("%s | %s", version, versionInfo["timestamp_installed"])) + } + + c.UI.Warn("") + c.UI.Warn(versionTrackingWarning) + c.UI.Warn("") + c.UI.Output(tableOutput(table, columnConfig)) + + return 0 +} diff --git a/command/version_history_test.go b/command/version_history_test.go new file mode 100644 index 000000000..26d4ef3c2 --- /dev/null +++ b/command/version_history_test.go @@ -0,0 +1,111 @@ +package command + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/hashicorp/vault/sdk/version" + "github.com/mitchellh/cli" +) + +func testVersionHistoryCommand(tb testing.TB) (*cli.MockUi, *VersionHistoryCommand) { + tb.Helper() + + ui := cli.NewMockUi() + return ui, &VersionHistoryCommand{ + BaseCommand: &BaseCommand{ + UI: ui, + }, + } +} + +func TestVersionHistoryCommand_TableOutput(t *testing.T) { + client, closer := testVaultServer(t) + defer closer() + + ui, cmd := testVersionHistoryCommand(t) + cmd.client = client + + code := cmd.Run([]string{}) + + if expectedCode := 0; code != expectedCode { + t.Fatalf("expected %d to be %d: %s", code, expectedCode, ui.ErrorWriter.String()) + } + + if errorString := ui.ErrorWriter.String(); !strings.Contains(errorString, versionTrackingWarning) { + t.Errorf("expected %q to contain %q", errorString, versionTrackingWarning) + } + + output := ui.OutputWriter.String() + + if !strings.Contains(output, version.Version) { + t.Errorf("expected %q to contain version %q", output, version.Version) + } +} + +func TestVersionHistoryCommand_JsonOutput(t *testing.T) { + client, closer := testVaultServer(t) + defer closer() + + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + runOpts := &RunOptions{ + Stdout: stdout, + Stderr: stderr, + Client: client, + } + + args, format, _ := setupEnv([]string{"version-history", "-format", "json"}) + if format != "json" { + t.Fatalf("expected format to be %q, actual %q", "json", format) + } + + code := RunCustom(args, runOpts) + + if expectedCode := 0; code != expectedCode { + t.Fatalf("expected %d to be %d: %s", code, expectedCode, stderr.String()) + } + + if stderrString := stderr.String(); !strings.Contains(stderrString, versionTrackingWarning) { + t.Errorf("expected %q to contain %q", stderrString, versionTrackingWarning) + } + + stdoutBytes := stdout.Bytes() + + if !json.Valid(stdoutBytes) { + t.Fatalf("expected output %q to be valid JSON", stdoutBytes) + } + + var versionHistoryResp map[string]interface{} + err := json.Unmarshal(stdoutBytes, &versionHistoryResp) + if err != nil { + t.Fatalf("failed to unmarshal json from STDOUT, err: %s", err.Error()) + } + + var respData map[string]interface{} + var ok bool + var keys []interface{} + var keyInfo map[string]interface{} + + if respData, ok = versionHistoryResp["data"].(map[string]interface{}); !ok { + t.Fatalf("expected data key to be map, actual: %#v", versionHistoryResp["data"]) + } + + if keys, ok = respData["keys"].([]interface{}); !ok { + t.Fatalf("expected keys to be array, actual: %#v", respData["keys"]) + } + + if keyInfo, ok = respData["key_info"].(map[string]interface{}); !ok { + t.Fatalf("expected key_info to be map, actual: %#v", respData["key_info"]) + } + + if len(keys) != 1 { + t.Fatalf("expected single version history entry for %q", version.Version) + } + + if keyInfo[version.Version] == nil { + t.Fatalf("expected version %s to be present in key_info, actual: %#v", version.Version, keyInfo) + } +} diff --git a/vault/core.go b/vault/core.go index 00521ff5e..c36fc55f1 100644 --- a/vault/core.go +++ b/vault/core.go @@ -1070,8 +1070,8 @@ func NewCore(conf *CoreConfig) (*Core, error) { // handleVersionTimeStamps stores the current version at the current time to // storage, and then loads all versions and upgrade timestamps out from storage. func (c *Core) handleVersionTimeStamps(ctx context.Context) error { - currentTime := time.Now() - isUpdated, err := c.storeVersionTimestamp(ctx, version.Version, currentTime) + currentTime := time.Now().UTC() + isUpdated, err := c.storeVersionTimestamp(ctx, version.Version, currentTime, false) if err != nil { return fmt.Errorf("error storing vault version: %w", err) } diff --git a/vault/core_util_common_test.go b/vault/core_util_common_test.go deleted file mode 100644 index 785bc56fc..000000000 --- a/vault/core_util_common_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package vault - -import ( - "context" - "testing" - "time" - - "github.com/hashicorp/vault/sdk/version" -) - -// TestStoreMultipleVaultVersions writes multiple versions of 1.9.0 and verifies that only -// the original timestamp is stored. -func TestStoreMultipleVaultVersions(t *testing.T) { - c, _, _ := TestCoreUnsealed(t) - upgradeTimePlusEpsilon := time.Now() - wasStored, err := c.storeVersionTimestamp(context.Background(), version.Version, upgradeTimePlusEpsilon.Add(30*time.Hour)) - if err != nil || wasStored { - t.Fatalf("vault version was re-stored: %v, err is: %s", wasStored, err.Error()) - } - upgradeTime, ok := c.versionTimestamps[version.Version] - if !ok { - t.Fatalf("no %s version timestamp found", version.Version) - } - if upgradeTime.After(upgradeTimePlusEpsilon) { - t.Fatalf("upgrade time for %s is incorrect: got %+v, expected less than %+v", version.Version, upgradeTime, upgradeTimePlusEpsilon) - } -} - -// TestGetOldestVersion verifies that FindOldestVersionTimestamp finds the oldest -// (in time) vault version stored. -func TestGetOldestVersion(t *testing.T) { - c, _, _ := TestCoreUnsealed(t) - upgradeTimePlusEpsilon := time.Now() - - // 1.6.2 is stored before 1.6.1, so even though it is a higher number, it should be returned. - c.storeVersionTimestamp(context.Background(), "1.6.2", upgradeTimePlusEpsilon.Add(-4*time.Hour)) - c.storeVersionTimestamp(context.Background(), "1.6.1", upgradeTimePlusEpsilon.Add(2*time.Hour)) - c.loadVersionTimestamps(c.activeContext) - if len(c.versionTimestamps) != 3 { - t.Fatalf("expected 3 entries in timestamps map after refresh, found: %d", len(c.versionTimestamps)) - } - v, tm, err := c.FindOldestVersionTimestamp() - if err != nil { - t.Fatal(err) - } - if v != "1.6.2" { - t.Fatalf("expected 1.6.2, found: %s", v) - } - if tm.Before(upgradeTimePlusEpsilon.Add(-6*time.Hour)) || tm.After(upgradeTimePlusEpsilon.Add(-2*time.Hour)) { - t.Fatalf("incorrect upgrade time logged: %v", tm) - } -} diff --git a/vault/logical_system.go b/vault/logical_system.go index 0ff3be572..007953760 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -4245,6 +4245,42 @@ type HAStatusNode struct { LastEcho *time.Time `json:"last_echo"` } +func (b *SystemBackend) handleVersionHistoryList(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + versions := make([]VaultVersion, 0) + respKeys := make([]string, 0) + + for versionString, ts := range b.Core.versionTimestamps { + versions = append(versions, VaultVersion{ + Version: versionString, + TimestampInstalled: ts, + }) + } + + sort.Slice(versions, func(i, j int) bool { + return versions[i].TimestampInstalled.Before(versions[j].TimestampInstalled) + }) + + respKeyInfo := map[string]interface{}{} + + for i, v := range versions { + respKeys = append(respKeys, v.Version) + + entry := map[string]interface{}{ + "timestamp_installed": v.TimestampInstalled.Format(time.RFC3339), + "previous_version": nil, + } + + if i > 0 { + entry["previous_version"] = versions[i-1].Version + } + + respKeyInfo[v.Version] = entry + } + + return logical.ListResponseWithInfo(respKeys, respKeyInfo), nil + +} + func sanitizePath(path string) string { if !strings.HasSuffix(path, "/") { path += "/" @@ -5036,4 +5072,13 @@ This path responds to the following HTTP methods. "List leases associated with this Vault cluster", "Requires sudo capability. List leases associated with this Vault cluster", }, + "version-history": { + "List historical version changes sorted by installation time in ascending order.", + ` +This path responds to the following HTTP methods. + + LIST / + Returns a list historical version changes sorted by installation time in ascending order. + `, + }, } diff --git a/vault/logical_system_integ_test.go b/vault/logical_system_integ_test.go index b76b8755b..db24fd076 100644 --- a/vault/logical_system_integ_test.go +++ b/vault/logical_system_integ_test.go @@ -22,6 +22,7 @@ import ( "github.com/hashicorp/vault/sdk/physical/inmem" lplugin "github.com/hashicorp/vault/sdk/plugin" "github.com/hashicorp/vault/sdk/plugin/mock" + "github.com/hashicorp/vault/sdk/version" "github.com/hashicorp/vault/vault" ) @@ -896,3 +897,68 @@ func TestSystemBackend_HAStatus(t *testing.T) { return nil }) } + +// TestSystemBackend_VersionHistory_unauthenticated tests the sys/version-history +// endpoint without providing a token. Requests to the endpoint must be +// authenticated and thus a 403 response is expected. +func TestSystemBackend_VersionHistory_unauthenticated(t *testing.T) { + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + client.SetToken("") + resp, err := client.Logical().List("sys/version-history") + + if resp != nil { + t.Fatalf("expected nil response, resp: %#v", resp) + } + + respErr, ok := err.(*api.ResponseError) + if !ok { + t.Fatalf("unexpected error type: err: %#v", err) + } + + if respErr.StatusCode != 403 { + t.Fatalf("expected response status to be 403, actual: %d", respErr.StatusCode) + } +} + +// TestSystemBackend_VersionHistory_authenticated tests the sys/version-history +// endpoint with authentication. Without synthetically altering the underlying +// core/versions storage entries, a single version entry should exist. +func TestSystemBackend_VersionHistory_authenticated(t *testing.T) { + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + resp, err := client.Logical().List("sys/version-history") + if err != nil || resp == nil { + t.Fatalf("request failed, err: %v, resp: %#v", err, resp) + } + + var ok bool + var keys []interface{} + var keyInfo map[string]interface{} + + if keys, ok = resp.Data["keys"].([]interface{}); !ok { + t.Fatalf("expected keys to be array, actual: %#v", resp.Data["keys"]) + } + + if keyInfo, ok = resp.Data["key_info"].(map[string]interface{}); !ok { + t.Fatalf("expected key_info to be map, actual: %#v", resp.Data["key_info"]) + } + + if len(keys) != 1 { + t.Fatalf("expected single version history entry for %q", version.Version) + } + + if keyInfo[version.Version] == nil { + t.Fatalf("expected version %s to be present in key_info, actual: %#v", version.Version, keyInfo) + } +} \ No newline at end of file diff --git a/vault/logical_system_paths.go b/vault/logical_system_paths.go index 273772175..3c44bf01b 100644 --- a/vault/logical_system_paths.go +++ b/vault/logical_system_paths.go @@ -489,6 +489,18 @@ func (b *SystemBackend) statusPaths() []*framework.Path { HelpSynopsis: strings.TrimSpace(sysHelp["ha-status"][0]), HelpDescription: strings.TrimSpace(sysHelp["ha-status"][1]), }, + { + Pattern: "version-history/$", + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{ + Callback: b.handleVersionHistoryList, + Summary: "Returns map of historical version change entries", + }, + }, + + HelpSynopsis: strings.TrimSpace(sysHelp["version-history"][0]), + HelpDescription: strings.TrimSpace(sysHelp["version-history"][1]), + }, } } diff --git a/vault/core_util_common.go b/vault/version_store.go similarity index 53% rename from vault/core_util_common.go rename to vault/version_store.go index ea47787df..5b665944f 100644 --- a/vault/core_util_common.go +++ b/vault/version_store.go @@ -11,45 +11,68 @@ import ( const vaultVersionPath string = "core/versions/" -// storeVersionTimestamp will store the version and timestamp pair to storage only if no entry -// for that version already exists in storage. -func (c *Core) storeVersionTimestamp(ctx context.Context, version string, currentTime time.Time) (bool, error) { - timeStamp, err := c.barrier.Get(ctx, vaultVersionPath+version) - if err != nil { - return false, err +// storeVersionTimestamp will store the version and timestamp pair to storage +// only if no entry for that version already exists in storage. Version +// timestamps were initially stored in local time. UTC should be used. Existing +// entries can be overwritten via the force flag. A bool will be returned +// denoting whether the entry was updated +func (c *Core) storeVersionTimestamp(ctx context.Context, version string, timestampInstalled time.Time, force bool) (bool, error) { + key := vaultVersionPath + version + + vaultVersion := VaultVersion{ + TimestampInstalled: timestampInstalled.UTC(), + Version: version, } - if timeStamp != nil { - return false, nil - } - - vaultVersion := VaultVersion{TimestampInstalled: currentTime, Version: version} marshalledVaultVersion, err := json.Marshal(vaultVersion) if err != nil { return false, err } - err = c.barrier.Put(ctx, &logical.StorageEntry{ - Key: vaultVersionPath + version, + newEntry := &logical.StorageEntry{ + Key: key, Value: marshalledVaultVersion, - }) + } + + if force { + // avoid storage lookup and write immediately + err = c.barrier.Put(ctx, newEntry) + + if err != nil { + return false, err + } + + return true, nil + } + + existingEntry, err := c.barrier.Get(ctx, key) if err != nil { return false, err } + + if existingEntry != nil { + return false, nil + } + + err = c.barrier.Put(ctx, newEntry) + + if err != nil { + return false, err + } + return true, nil } // FindOldestVersionTimestamp searches for the vault version with the oldest -// upgrade timestamp from storage. The earliest version this can be (barring -// downgrades) is 1.9.0. +// upgrade timestamp from storage. The earliest version this can be is 1.9.0. func (c *Core) FindOldestVersionTimestamp() (string, time.Time, error) { if c.versionTimestamps == nil || len(c.versionTimestamps) == 0 { return "", time.Time{}, fmt.Errorf("version timestamps are not initialized") } - // initialize oldestUpgradeTime to current time - oldestUpgradeTime := time.Now() + oldestUpgradeTime := time.Now().UTC() var oldestVersion string + for version, upgradeTime := range c.versionTimestamps { if upgradeTime.Before(oldestUpgradeTime) { oldestVersion = version @@ -59,18 +82,19 @@ func (c *Core) FindOldestVersionTimestamp() (string, time.Time, error) { return oldestVersion, oldestUpgradeTime, nil } -// loadVersionTimestamps loads all the vault versions and associated -// upgrade timestamps from storage. -func (c *Core) loadVersionTimestamps(ctx context.Context) (retErr error) { +// loadVersionTimestamps loads all the vault versions and associated upgrade +// timestamps from storage. Version timestamps were originally stored in local +// time. A timestamp that is not in UTC will be rewritten to storage as UTC. +func (c *Core) loadVersionTimestamps(ctx context.Context) error { vaultVersions, err := c.barrier.List(ctx, vaultVersionPath) if err != nil { - return fmt.Errorf("unable to retrieve vault versions from storage: %+w", err) + return fmt.Errorf("unable to retrieve vault versions from storage: %w", err) } for _, versionPath := range vaultVersions { version, err := c.barrier.Get(ctx, vaultVersionPath+versionPath) if err != nil { - return fmt.Errorf("unable to read vault version at path %s: err %+w", versionPath, err) + return fmt.Errorf("unable to read vault version at path %s: err %w", versionPath, err) } if version == nil { return fmt.Errorf("nil version stored at path %s", versionPath) @@ -83,7 +107,25 @@ func (c *Core) loadVersionTimestamps(ctx context.Context) (retErr error) { if vaultVersion.Version == "" || vaultVersion.TimestampInstalled.IsZero() { return fmt.Errorf("found empty serialized vault version at path %s", versionPath) } - c.versionTimestamps[vaultVersion.Version] = vaultVersion.TimestampInstalled + + timestampInstalled := vaultVersion.TimestampInstalled + + // self-heal entries that were not stored in UTC + if timestampInstalled.Location() != time.UTC { + timestampInstalled = timestampInstalled.UTC() + isUpdated, err := c.storeVersionTimestamp(ctx, vaultVersion.Version, timestampInstalled, true) + + if err != nil { + c.logger.Warn("failed to rewrite vault version timestamp as UTC", "error", err) + } + + if isUpdated { + c.logger.Info("self-healed pre-existing vault version in UTC", + "vault version", vaultVersion.Version, "UTC time", timestampInstalled) + } + } + + c.versionTimestamps[vaultVersion.Version] = timestampInstalled } return nil } diff --git a/vault/version_store_test.go b/vault/version_store_test.go new file mode 100644 index 000000000..0c3b2ec00 --- /dev/null +++ b/vault/version_store_test.go @@ -0,0 +1,99 @@ +package vault + +import ( + "context" + "testing" + "time" + + "github.com/hashicorp/vault/sdk/version" +) + +// TestVersionStore_StoreMultipleVaultVersions writes multiple versions of 1.9.0 and verifies that only +// the original timestamp is stored. +func TestVersionStore_StoreMultipleVaultVersions(t *testing.T) { + c, _, _ := TestCoreUnsealed(t) + upgradeTimePlusEpsilon := time.Now().UTC() + wasStored, err := c.storeVersionTimestamp(context.Background(), version.Version, upgradeTimePlusEpsilon.Add(30*time.Hour), false) + if err != nil || wasStored { + t.Fatalf("vault version was re-stored: %v, err is: %s", wasStored, err.Error()) + } + upgradeTime, ok := c.versionTimestamps[version.Version] + if !ok { + t.Fatalf("no %s version timestamp found", version.Version) + } + if upgradeTime.After(upgradeTimePlusEpsilon) { + t.Fatalf("upgrade time for %s is incorrect: got %+v, expected less than %+v", version.Version, upgradeTime, upgradeTimePlusEpsilon) + } +} + +// TestVersionStore_GetOldestVersion verifies that FindOldestVersionTimestamp finds the oldest +// (in time) vault version stored. +func TestVersionStore_GetOldestVersion(t *testing.T) { + c, _, _ := TestCoreUnsealed(t) + upgradeTimePlusEpsilon := time.Now().UTC() + + // 1.6.2 is stored before 1.6.1, so even though it is a higher number, it should be returned. + versionEntries := []struct{version string; ts time.Time}{ + {"1.6.2", upgradeTimePlusEpsilon.Add(-4*time.Hour)}, + {"1.6.1", upgradeTimePlusEpsilon.Add(2*time.Hour)}, + } + + for _, entry := range versionEntries{ + _, err := c.storeVersionTimestamp(context.Background(), entry.version, entry.ts, false) + if err != nil { + t.Fatalf("failed to write version entry %#v, err: %s", entry, err.Error()) + } + } + + err := c.loadVersionTimestamps(c.activeContext) + if err != nil { + t.Fatalf("failed to populate version history cache, err: %s", err.Error()) + } + + if len(c.versionTimestamps) != 3 { + t.Fatalf("expected 3 entries in timestamps map after refresh, found: %d", len(c.versionTimestamps)) + } + v, tm, err := c.FindOldestVersionTimestamp() + if err != nil { + t.Fatal(err) + } + if v != "1.6.2" { + t.Fatalf("expected 1.6.2, found: %s", v) + } + if tm.Before(upgradeTimePlusEpsilon.Add(-6*time.Hour)) || tm.After(upgradeTimePlusEpsilon.Add(-2*time.Hour)) { + t.Fatalf("incorrect upgrade time logged: %v", tm) + } +} + +func TestVersionStore_SelfHealUTC(t *testing.T) { + c, _, _ := TestCoreUnsealed(t) + estLoc, err := time.LoadLocation("EST") + if err != nil { + t.Fatalf("failed to load location, err: %s", err.Error()) + } + + nowEST := time.Now().In(estLoc) + + versionEntries := []struct{version string; ts time.Time}{ + {"1.9.0", nowEST.Add(24*time.Hour)}, + {"1.9.1", nowEST.Add(48*time.Hour)}, + } + + for _, entry := range versionEntries{ + _, err := c.storeVersionTimestamp(context.Background(), entry.version, entry.ts, false) + if err != nil { + t.Fatalf("failed to write version entry %#v, err: %s", entry, err.Error()) + } + } + + err = c.loadVersionTimestamps(c.activeContext) + if err != nil { + t.Fatalf("failed to load version timestamps, err: %s", err.Error()) + } + + for versionStr, ts := range c.versionTimestamps { + if ts.Location() != time.UTC { + t.Fatalf("failed to convert %s timestamp %s to UTC", versionStr, ts) + } + } +} diff --git a/website/content/api-docs/system/version-history.mdx b/website/content/api-docs/system/version-history.mdx new file mode 100644 index 000000000..ee732883f --- /dev/null +++ b/website/content/api-docs/system/version-history.mdx @@ -0,0 +1,55 @@ +--- +layout: api +page_title: /sys/version-history - HTTP API +description: The `/sys/version-history` endpoint is used to retrieve the version history of a Vault. +--- + +# `/sys/version-history` + +~> **NOTE**: Tracking Vault version history was added in Vault version 1.9.0. Entries for versions installed prior to version 1.9.0 will not be available via this API. +This API was added in 1.10.0. + +The `/sys/version-history` endpoint is used to retrieve the version history of a Vault. + +## Read Version History + +This endpoint returns the version history of the Vault. The response will contain the following keys: + +- `keys`: a list of installed versions in chronological order based on the time installed +- `key_info`: a map indexed by the versions found in the `keys` list containing the following subkeys: + - `previous_version`: the version installed prior to this version or `null` if no prior version exists + - `timestamp_installed`: the time (in UTC) at which the version was installed + +| Method | Path | +| :----- | :--------------------- | +| `GET` | `/sys/version-history` | + +### Sample Request + +```shell-session +$ curl \ + --header "X-Vault-Token: ..." \ + http://127.0.0.1:8200/v1/sys/version-history +``` + +### Sample Response + +```json +{ + "keys": ["1.9.0", "1.9.1", "1.9.2"], + "key_info": { + "1.9.0": { + "previous_version": null, + "timestamp_installed": "2021-11-18T10:23:16Z" + }, + "1.9.1": { + "previous_version": "1.9.0", + "timestamp_installed": "2021-12-13T11:09:52Z" + }, + "1.9.2": { + "previous_version": "1.9.1", + "timestamp_installed": "2021-12-23T10:56:37Z" + } + } +} +``` diff --git a/website/content/docs/commands/version-history.mdx b/website/content/docs/commands/version-history.mdx new file mode 100644 index 000000000..508a6eafb --- /dev/null +++ b/website/content/docs/commands/version-history.mdx @@ -0,0 +1,31 @@ +--- +layout: docs +page_title: version-history - Command +description: |- + The "version-history" command prints the historical list of installed Vault versions in chronological order. +--- + +# version-history + +The `version-history` command prints the historical list of installed Vault versions in chronological order. + +```shell-session +Note: Version tracking was added in 1.9.0. Earlier versions have not been tracked. + +Version Installation Time +------- ----------------- +1.9.0 2021-11-18T10:23:16Z +1.9.1 2022-12-13T11:09:52Z +1.9.2 2021-12-23T10:56:37Z +``` + +## Usage + +The following flags are available in addition to the [standard set of +flags](/docs/commands) included on all commands. + +### Output Options + +- `-format` `(string: "table")` - Print the output in the given format. Valid + formats are "table" or json". This can also be specified via the + `VAULT_FORMAT` environment variable. diff --git a/website/data/api-docs-nav-data.json b/website/data/api-docs-nav-data.json index 3d7fa9f6e..9b571d9e6 100644 --- a/website/data/api-docs-nav-data.json +++ b/website/data/api-docs-nav-data.json @@ -616,6 +616,10 @@ "title": "/sys/unseal", "path": "system/unseal" }, + { + "title": "/sys/version-history", + "path": "system/version-history" + }, { "title": "/sys/wrapping/lookup", "path": "system/wrapping-lookup" diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 655fb8cb8..d8702f051 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -757,6 +757,10 @@ "title": "version", "path": "commands/version" }, + { + "title": "version-history", + "path": "commands/version-history" + }, { "title": "write", "path": "commands/write" diff --git a/website/redirects.next.js b/website/redirects.next.js index 55030fece..1623a08b2 100644 --- a/website/redirects.next.js +++ b/website/redirects.next.js @@ -215,6 +215,11 @@ module.exports = [ destination: '/api-docs/system/unseal', permanent: true, }, + { + source: '/docs/http/sys-version-history', + destination: '/api-docs/system/version-history', + permanent: true, + }, { source: '/docs/install/install', destination: '/docs/install',