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 <ncabatoff@hashicorp.com>

* 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 <ncabatoff@hashicorp.com>
This commit is contained in:
Chris Capurso 2022-02-14 15:26:57 -05:00 committed by GitHub
parent 0712ef13fc
commit f9e9b4d327
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 638 additions and 78 deletions

3
changelog/13766.txt Normal file
View File

@ -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`
```

View File

@ -653,6 +653,11 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) {
BaseCommand: getBaseCommand(), BaseCommand: getBaseCommand(),
}, nil }, nil
}, },
"version-history": func() (cli.Command, error) {
return &VersionHistoryCommand{
BaseCommand: getBaseCommand(),
}, nil
},
"write": func() (cli.Command, error) { "write": func() (cli.Command, error) {
return &WriteCommand{ return &WriteCommand{
BaseCommand: getBaseCommand(), BaseCommand: getBaseCommand(),

130
command/version_history.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

View File

@ -1070,8 +1070,8 @@ func NewCore(conf *CoreConfig) (*Core, error) {
// handleVersionTimeStamps stores the current version at the current time to // handleVersionTimeStamps stores the current version at the current time to
// storage, and then loads all versions and upgrade timestamps out from storage. // storage, and then loads all versions and upgrade timestamps out from storage.
func (c *Core) handleVersionTimeStamps(ctx context.Context) error { func (c *Core) handleVersionTimeStamps(ctx context.Context) error {
currentTime := time.Now() currentTime := time.Now().UTC()
isUpdated, err := c.storeVersionTimestamp(ctx, version.Version, currentTime) isUpdated, err := c.storeVersionTimestamp(ctx, version.Version, currentTime, false)
if err != nil { if err != nil {
return fmt.Errorf("error storing vault version: %w", err) return fmt.Errorf("error storing vault version: %w", err)
} }

View File

@ -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)
}
}

View File

@ -4245,6 +4245,42 @@ type HAStatusNode struct {
LastEcho *time.Time `json:"last_echo"` 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 { func sanitizePath(path string) string {
if !strings.HasSuffix(path, "/") { if !strings.HasSuffix(path, "/") {
path += "/" path += "/"
@ -5036,4 +5072,13 @@ This path responds to the following HTTP methods.
"List leases associated with this Vault cluster", "List leases associated with this Vault cluster",
"Requires sudo capability. 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.
`,
},
} }

View File

@ -22,6 +22,7 @@ import (
"github.com/hashicorp/vault/sdk/physical/inmem" "github.com/hashicorp/vault/sdk/physical/inmem"
lplugin "github.com/hashicorp/vault/sdk/plugin" lplugin "github.com/hashicorp/vault/sdk/plugin"
"github.com/hashicorp/vault/sdk/plugin/mock" "github.com/hashicorp/vault/sdk/plugin/mock"
"github.com/hashicorp/vault/sdk/version"
"github.com/hashicorp/vault/vault" "github.com/hashicorp/vault/vault"
) )
@ -896,3 +897,68 @@ func TestSystemBackend_HAStatus(t *testing.T) {
return nil 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)
}
}

View File

@ -489,6 +489,18 @@ func (b *SystemBackend) statusPaths() []*framework.Path {
HelpSynopsis: strings.TrimSpace(sysHelp["ha-status"][0]), HelpSynopsis: strings.TrimSpace(sysHelp["ha-status"][0]),
HelpDescription: strings.TrimSpace(sysHelp["ha-status"][1]), 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]),
},
} }
} }

View File

@ -11,45 +11,68 @@ import (
const vaultVersionPath string = "core/versions/" const vaultVersionPath string = "core/versions/"
// storeVersionTimestamp will store the version and timestamp pair to storage only if no entry // storeVersionTimestamp will store the version and timestamp pair to storage
// for that version already exists in storage. // only if no entry for that version already exists in storage. Version
func (c *Core) storeVersionTimestamp(ctx context.Context, version string, currentTime time.Time) (bool, error) { // timestamps were initially stored in local time. UTC should be used. Existing
timeStamp, err := c.barrier.Get(ctx, vaultVersionPath+version) // entries can be overwritten via the force flag. A bool will be returned
if err != nil { // denoting whether the entry was updated
return false, err 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) marshalledVaultVersion, err := json.Marshal(vaultVersion)
if err != nil { if err != nil {
return false, err return false, err
} }
err = c.barrier.Put(ctx, &logical.StorageEntry{ newEntry := &logical.StorageEntry{
Key: vaultVersionPath + version, Key: key,
Value: marshalledVaultVersion, 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 { if err != nil {
return false, err return false, err
} }
if existingEntry != nil {
return false, nil
}
err = c.barrier.Put(ctx, newEntry)
if err != nil {
return false, err
}
return true, nil return true, nil
} }
// FindOldestVersionTimestamp searches for the vault version with the oldest // FindOldestVersionTimestamp searches for the vault version with the oldest
// upgrade timestamp from storage. The earliest version this can be (barring // upgrade timestamp from storage. The earliest version this can be is 1.9.0.
// downgrades) is 1.9.0.
func (c *Core) FindOldestVersionTimestamp() (string, time.Time, error) { func (c *Core) FindOldestVersionTimestamp() (string, time.Time, error) {
if c.versionTimestamps == nil || len(c.versionTimestamps) == 0 { if c.versionTimestamps == nil || len(c.versionTimestamps) == 0 {
return "", time.Time{}, fmt.Errorf("version timestamps are not initialized") return "", time.Time{}, fmt.Errorf("version timestamps are not initialized")
} }
// initialize oldestUpgradeTime to current time oldestUpgradeTime := time.Now().UTC()
oldestUpgradeTime := time.Now()
var oldestVersion string var oldestVersion string
for version, upgradeTime := range c.versionTimestamps { for version, upgradeTime := range c.versionTimestamps {
if upgradeTime.Before(oldestUpgradeTime) { if upgradeTime.Before(oldestUpgradeTime) {
oldestVersion = version oldestVersion = version
@ -59,18 +82,19 @@ func (c *Core) FindOldestVersionTimestamp() (string, time.Time, error) {
return oldestVersion, oldestUpgradeTime, nil return oldestVersion, oldestUpgradeTime, nil
} }
// loadVersionTimestamps loads all the vault versions and associated // loadVersionTimestamps loads all the vault versions and associated upgrade
// upgrade timestamps from storage. // timestamps from storage. Version timestamps were originally stored in local
func (c *Core) loadVersionTimestamps(ctx context.Context) (retErr error) { // 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) vaultVersions, err := c.barrier.List(ctx, vaultVersionPath)
if err != nil { 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 { for _, versionPath := range vaultVersions {
version, err := c.barrier.Get(ctx, vaultVersionPath+versionPath) version, err := c.barrier.Get(ctx, vaultVersionPath+versionPath)
if err != nil { 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 { if version == nil {
return fmt.Errorf("nil version stored at path %s", versionPath) 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() { if vaultVersion.Version == "" || vaultVersion.TimestampInstalled.IsZero() {
return fmt.Errorf("found empty serialized vault version at path %s", versionPath) 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 return nil
} }

View File

@ -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)
}
}
}

View File

@ -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"
}
}
}
```

View File

@ -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.

View File

@ -616,6 +616,10 @@
"title": "<code>/sys/unseal</code>", "title": "<code>/sys/unseal</code>",
"path": "system/unseal" "path": "system/unseal"
}, },
{
"title": "<code>/sys/version-history</code>",
"path": "system/version-history"
},
{ {
"title": "<code>/sys/wrapping/lookup</code>", "title": "<code>/sys/wrapping/lookup</code>",
"path": "system/wrapping-lookup" "path": "system/wrapping-lookup"

View File

@ -757,6 +757,10 @@
"title": "<code>version</code>", "title": "<code>version</code>",
"path": "commands/version" "path": "commands/version"
}, },
{
"title": "<code>version-history</code>",
"path": "commands/version-history"
},
{ {
"title": "<code>write</code>", "title": "<code>write</code>",
"path": "commands/write" "path": "commands/write"

View File

@ -215,6 +215,11 @@ module.exports = [
destination: '/api-docs/system/unseal', destination: '/api-docs/system/unseal',
permanent: true, permanent: true,
}, },
{
source: '/docs/http/sys-version-history',
destination: '/api-docs/system/version-history',
permanent: true,
},
{ {
source: '/docs/install/install', source: '/docs/install/install',
destination: '/docs/install', destination: '/docs/install',