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:
parent
0712ef13fc
commit
f9e9b4d327
|
@ -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`
|
||||||
|
```
|
|
@ -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(),
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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.
|
||||||
|
`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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]),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
if err != nil {
|
||||||
return false, err
|
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
|
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
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
|
@ -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.
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in New Issue