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',