-
Notifications
You must be signed in to change notification settings - Fork 4.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
cli: Add 'agent generate-config' sub-command #20530
Changes from 8 commits
0b035dd
2a80886
647843c
7a8f1d5
2533835
a3e3172
8429aab
74aed71
12ec369
1eeb8d7
8027803
20107dc
46b2320
2551557
159c813
1f83221
5a9e550
a5d9cf2
b5732e5
baa3ec5
c8225ba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
```release-note:feature | ||
cli: Add 'agent generate-config' sub-command | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,393 @@ | ||
// Copyright (c) HashiCorp, Inc. | ||
// SPDX-License-Identifier: MPL-2.0 | ||
|
||
package command | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"os" | ||
paths "path" | ||
"sort" | ||
"strings" | ||
"unicode" | ||
|
||
"github.com/hashicorp/hcl/v2/gohcl" | ||
"github.com/hashicorp/hcl/v2/hclwrite" | ||
"github.com/hashicorp/vault/api" | ||
"github.com/mitchellh/cli" | ||
"github.com/mitchellh/go-homedir" | ||
"github.com/posener/complete" | ||
) | ||
|
||
var ( | ||
_ cli.Command = (*AgentGenerateConfigCommand)(nil) | ||
_ cli.CommandAutocomplete = (*AgentGenerateConfigCommand)(nil) | ||
) | ||
|
||
type AgentGenerateConfigCommand struct { | ||
*BaseCommand | ||
|
||
flagType string | ||
flagPaths []string | ||
flagExec string | ||
} | ||
|
||
func (c *AgentGenerateConfigCommand) Synopsis() string { | ||
return "Generate a Vault Agent configuration file." | ||
} | ||
|
||
func (c *AgentGenerateConfigCommand) Help() string { | ||
helpText := ` | ||
Usage: vault agent generate-config [options] [args] | ||
|
||
` + c.Flags().Help() | ||
|
||
return strings.TrimSpace(helpText) | ||
} | ||
|
||
func (c *AgentGenerateConfigCommand) Flags() *FlagSets { | ||
set := NewFlagSets(c.UI) | ||
|
||
// Common Options | ||
f := set.NewFlagSet("Command Options") | ||
|
||
f.StringVar(&StringVar{ | ||
Name: "type", | ||
Target: &c.flagType, | ||
Usage: "Type of configuration file to generate; currently, only 'env-template' is supported.", | ||
Completion: complete.PredictSet( | ||
"env-template", | ||
), | ||
}) | ||
|
||
f.StringSliceVar(&StringSliceVar{ | ||
Name: "path", | ||
Target: &c.flagPaths, | ||
Usage: "Path to a kv-v1 or kv-v2 secret (e.g. secret/data/foo, kv-v2/prefix/*); multiple secrets and tail '*' wildcards are allowed.", | ||
Completion: c.PredictVaultFolders(), | ||
}) | ||
|
||
f.StringVar(&StringVar{ | ||
Name: "exec", | ||
Target: &c.flagExec, | ||
Default: "env", | ||
Usage: "The command to execute in env-template mode.", | ||
}) | ||
|
||
return set | ||
} | ||
|
||
func (c *AgentGenerateConfigCommand) AutocompleteArgs() complete.Predictor { | ||
return complete.PredictNothing | ||
} | ||
|
||
func (c *AgentGenerateConfigCommand) AutocompleteFlags() complete.Flags { | ||
return c.Flags().Completions() | ||
} | ||
|
||
func (c *AgentGenerateConfigCommand) Run(args []string) int { | ||
ctx := context.Background() | ||
|
||
flags := c.Flags() | ||
|
||
if err := flags.Parse(args); err != nil { | ||
c.UI.Error(err.Error()) | ||
return 1 | ||
} | ||
|
||
args = flags.Args() | ||
|
||
if len(args) > 1 { | ||
c.UI.Error(fmt.Sprintf("Too many arguments (expected at most 1, got %d)", len(args))) | ||
return 1 | ||
} | ||
|
||
if c.flagType == "" { | ||
c.UI.Error(`Please specify a -type flag; currently only -type="env-template" is supported.`) | ||
return 1 | ||
} | ||
|
||
if c.flagType != "env-template" { | ||
c.UI.Error(fmt.Sprintf(`%q is not a supported configuration type; currently only -type="env-template" is supported.`, c.flagType)) | ||
return 1 | ||
} | ||
|
||
client, err := c.Client() | ||
if err != nil { | ||
c.UI.Error(err.Error()) | ||
return 2 | ||
} | ||
|
||
templates, err := constructTemplates(ctx, client, c.flagPaths) | ||
if err != nil { | ||
c.UI.Error(fmt.Sprintf("Error generating templates: %v", err)) | ||
return 2 | ||
} | ||
|
||
var execCommand []string | ||
if c.flagExec != "" { | ||
execCommand = strings.Split(c.flagExec, " ") | ||
} else { | ||
execCommand = []string{"env"} | ||
} | ||
|
||
tokenPath, err := homedir.Expand("~/.vault-token") | ||
if err != nil { | ||
c.UI.Error(fmt.Sprintf("Could not expand home directory: %v", err)) | ||
return 2 | ||
} | ||
dhuckins marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
config := generatedConfig{ | ||
AutoAuth: generatedConfigAutoAuth{ | ||
Method: generatedConfigAutoAuthMethod{ | ||
Type: "token_file", | ||
averche marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Config: generatedConfigAutoAuthMethodConfig{ | ||
TokenFilePath: tokenPath, | ||
}, | ||
}, | ||
}, | ||
TemplateConfig: generatedConfigTemplateConfig{ | ||
StaticSecretRendereInterval: "30s", | ||
averche marked this conversation as resolved.
Show resolved
Hide resolved
averche marked this conversation as resolved.
Show resolved
Hide resolved
|
||
ExitOnRetryFailure: true, | ||
}, | ||
Vault: generatedConfigVault{ | ||
Address: client.Address(), | ||
}, | ||
EnvTemplates: templates, | ||
Exec: generatedConfigExec{ | ||
Command: execCommand, | ||
RestartOnSecretChanges: "always", | ||
RestartKillSignal: "SIGTERM", | ||
}, | ||
} | ||
|
||
var configPath string | ||
if len(args) == 1 { | ||
configPath = args[0] | ||
} else { | ||
configPath = "agent.hcl" | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should the default be stdout? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe 🤷 We kind of want to give a warning about the token auto-auth method so I'm slightly leaning towards writing to a file. We could also require a file to be specified. |
||
|
||
contents := hclwrite.NewEmptyFile() | ||
|
||
gohcl.EncodeIntoBody(&config, contents.Body()) | ||
|
||
f, err := os.Create(configPath) | ||
if err != nil { | ||
c.UI.Error(fmt.Sprintf("Could not create configuration file %q: %v", configPath, err)) | ||
return 1 | ||
} | ||
defer func() { | ||
if err := f.Close(); err != nil { | ||
c.UI.Error(fmt.Sprintf("Could not close configuration file %q: %v", configPath, err)) | ||
} | ||
}() | ||
|
||
if _, err := contents.WriteTo(f); err != nil { | ||
c.UI.Error(fmt.Sprintf("Could not write to configuration file %q: %v", configPath, err)) | ||
return 1 | ||
} | ||
|
||
c.UI.Info(fmt.Sprintf("Successfully generated %q configuration file!", configPath)) | ||
|
||
return 0 | ||
} | ||
|
||
func constructTemplates(ctx context.Context, client *api.Client, paths []string) ([]generatedConfigEnvTemplate, error) { | ||
var templates []generatedConfigEnvTemplate | ||
|
||
for _, path := range paths { | ||
path = sanitizePath(path) | ||
|
||
mountPath, v2, err := isKVv2(path, client) | ||
if err != nil { | ||
return nil, fmt.Errorf("could not validate secret path %q: %w", path, err) | ||
} | ||
|
||
switch { | ||
case strings.HasSuffix(path, "/*"): | ||
// this path contains a tail wildcard, attempt to walk the tree | ||
t, err := constructTemplatesFromTree(ctx, client, path[:len(path)-2], mountPath, v2) | ||
if err != nil { | ||
return nil, fmt.Errorf("could not traverse sercet at %q: %w", path, err) | ||
} | ||
templates = append(templates, t...) | ||
|
||
case strings.Contains(path, "*"): | ||
// don't allow any other wildcards | ||
return nil, fmt.Errorf("the path %q cannot contain '*' wildcard characters except as the last element of the path", path) | ||
|
||
default: | ||
// regular secret path | ||
t, err := constructTemplatesFromSecret(ctx, client, path, mountPath, v2) | ||
if err != nil { | ||
return nil, fmt.Errorf("could not read secret at %q: %v", path, err) | ||
} | ||
templates = append(templates, t...) | ||
} | ||
} | ||
|
||
return templates, nil | ||
} | ||
|
||
func constructTemplatesFromTree(ctx context.Context, client *api.Client, path, mountPath string, v2 bool) ([]generatedConfigEnvTemplate, error) { | ||
var templates []generatedConfigEnvTemplate | ||
|
||
if v2 { | ||
metadataPath := strings.Replace( | ||
path, | ||
paths.Join(mountPath, "data"), | ||
paths.Join(mountPath, "metadata"), | ||
1, | ||
) | ||
if path != metadataPath { | ||
path = metadataPath | ||
} else { | ||
path = addPrefixToKVPath(path, mountPath, "metadata", true) | ||
} | ||
} | ||
|
||
err := walkSecretsTree(ctx, client, path, func(child string, directory bool) error { | ||
if directory { | ||
return nil | ||
} | ||
|
||
dataPath := strings.Replace( | ||
child, | ||
paths.Join(mountPath, "metadata"), | ||
paths.Join(mountPath, "data"), | ||
1, | ||
) | ||
|
||
t, err := constructTemplatesFromSecret(ctx, client, dataPath, mountPath, v2) | ||
if err != nil { | ||
return err | ||
} | ||
templates = append(templates, t...) | ||
|
||
return nil | ||
}) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
return templates, nil | ||
} | ||
|
||
func constructTemplatesFromSecret(ctx context.Context, client *api.Client, path, mountPath string, v2 bool) ([]generatedConfigEnvTemplate, error) { | ||
var templates []generatedConfigEnvTemplate | ||
|
||
if v2 { | ||
path = addPrefixToKVPath(path, mountPath, "data", true) | ||
} | ||
|
||
resp, err := client.Logical().ReadWithContext(ctx, path) | ||
if err != nil { | ||
return nil, fmt.Errorf("error querying: %w", err) | ||
} | ||
if resp == nil { | ||
return nil, fmt.Errorf("secret not found") | ||
} | ||
|
||
var data map[string]interface{} | ||
if v2 { | ||
internal, ok := resp.Data["data"] | ||
if !ok { | ||
return nil, fmt.Errorf("secret.Data not found") | ||
} | ||
data = internal.(map[string]interface{}) | ||
} else { | ||
data = resp.Data | ||
} | ||
|
||
fields := make([]string, 0, len(data)) | ||
|
||
for field := range data { | ||
fields = append(fields, field) | ||
} | ||
|
||
// sort for a deterministic output | ||
sort.Strings(fields) | ||
|
||
var dataContents string | ||
if v2 { | ||
dataContents = ".Data.data" | ||
} else { | ||
dataContents = ".Data" | ||
} | ||
|
||
for _, field := range fields { | ||
templates = append(templates, generatedConfigEnvTemplate{ | ||
Name: constructDefaultEnvironmentKey(path, field), | ||
Contents: fmt.Sprintf(`{{ with secret "%s" }}{{ %s.%s }}{{ end }}`, path, dataContents, field), | ||
ErrorOnMissingKey: true, | ||
}) | ||
} | ||
|
||
return templates, nil | ||
} | ||
|
||
func constructDefaultEnvironmentKey(path string, field string) string { | ||
pathParts := strings.Split(path, "/") | ||
pathPartsLast := pathParts[len(pathParts)-1] | ||
|
||
notLetterOrNumber := func(r rune) bool { | ||
return !unicode.IsLetter(r) && !unicode.IsNumber(r) | ||
} | ||
|
||
p1 := strings.FieldsFunc(pathPartsLast, notLetterOrNumber) | ||
p2 := strings.FieldsFunc(field, notLetterOrNumber) | ||
|
||
keyParts := append(p1, p2...) | ||
|
||
return strings.ToUpper(strings.Join(keyParts, "_")) | ||
} | ||
|
||
// Below, we are redefining a subset of the configuration-related structures | ||
// defined under command/agent/config. Using these structures we can tailor the | ||
// output of the generated config, while using the original structures would | ||
// have produced an HCL document with many empty fields. The structures below | ||
// should not be used for anything other than generation. | ||
|
||
type generatedConfig struct { | ||
AutoAuth generatedConfigAutoAuth `hcl:"auto_auth,block"` | ||
TemplateConfig generatedConfigTemplateConfig `hcl:"template_config,block"` | ||
Vault generatedConfigVault `hcl:"vault,block"` | ||
EnvTemplates []generatedConfigEnvTemplate `hcl:"env_template,block"` | ||
Exec generatedConfigExec `hcl:"exec,block"` | ||
} | ||
|
||
type generatedConfigTemplateConfig struct { | ||
StaticSecretRendereInterval string `hcl:"static_secret_render_interval"` | ||
averche marked this conversation as resolved.
Show resolved
Hide resolved
|
||
ExitOnRetryFailure bool `hcl:"exit_on_retry_failure"` | ||
} | ||
|
||
type generatedConfigExec struct { | ||
Command []string `hcl:"command"` | ||
RestartOnSecretChanges string `hcl:"restart_on_secret_changes"` | ||
RestartKillSignal string `hcl:"restart_kill_signal"` | ||
} | ||
|
||
type generatedConfigEnvTemplate struct { | ||
Name string `hcl:"name,label"` | ||
Contents string `hcl:"contents,attr"` | ||
ErrorOnMissingKey bool `hcl:"error_on_missing_key"` | ||
} | ||
|
||
type generatedConfigVault struct { | ||
Address string `hcl:"address"` | ||
} | ||
|
||
type generatedConfigAutoAuth struct { | ||
Method generatedConfigAutoAuthMethod `hcl:"method,block"` | ||
} | ||
|
||
type generatedConfigAutoAuthMethod struct { | ||
Type string `hcl:"type"` | ||
Config generatedConfigAutoAuthMethodConfig `hcl:"config,block"` | ||
} | ||
|
||
type generatedConfigAutoAuthMethodConfig struct { | ||
TokenFilePath string `hcl:"token_file_path"` | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@averche could you please update this changelog file using the format for announcing a new feature? https://hashicorp.atlassian.net/wiki/spaces/VAULT/pages/1311244491/Changelog+Process#New-and-Major-Features
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mladlow should we also make that change for #20739 ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dhuckins Are these two PRs the same or different "features"? If you scroll through historical changelog sections for major releases, you can see what's been called out in the "features" section. For the features section, it's expected that features will have multiple PRs and it doesn't always make sense to have a changelog entry for every PR that composes a major new feature.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
gotcha, same feature (at least they are related). so just one should be fine. thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like in the RC changelog this line is still showing up in the Features section - https://github.com/hashicorp/vault/pull/21077/files#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4edR43. Could someone update the file on the 1.14 release branch OR delete it on that branch please?