Skip to content

Commit 0e00e3b

Browse files
committed
Merge branch 'feature/ansible-bootstrap' into develop
2 parents 4d88be0 + 1227669 commit 0e00e3b

7 files changed

Lines changed: 1996 additions & 79 deletions

File tree

internal/config/config.go

Lines changed: 137 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -184,16 +184,36 @@ type PluginConfig struct {
184184

185185
// AnsiblePluginConfig holds configuration for the ansible plugin.
186186
type AnsiblePluginConfig struct {
187-
InventoryFormat string `yaml:"inventory_format,omitempty"`
188-
InventoryStyle string `yaml:"inventory_style,omitempty"`
189-
InventoryVars map[string]string `yaml:"inventory_vars,omitempty"`
190-
DefaultUser string `yaml:"default_user,omitempty"`
191-
DefaultPassword string `yaml:"default_password,omitempty"`
192-
SSHPrivateKeyFile string `yaml:"ssh_private_key_file,omitempty"`
193-
DefaultLimitMode string `yaml:"default_limit_mode,omitempty"`
194-
AskPass bool `yaml:"ask_pass,omitempty"`
195-
AskBecomePass bool `yaml:"ask_become_pass,omitempty"`
196-
ExtraArgs []string `yaml:"extra_args,omitempty"`
187+
InventoryFormat string `yaml:"inventory_format,omitempty"`
188+
InventoryStyle string `yaml:"inventory_style,omitempty"`
189+
InventoryVars map[string]string `yaml:"inventory_vars,omitempty"`
190+
DefaultUser string `yaml:"default_user,omitempty"`
191+
DefaultPassword string `yaml:"default_password,omitempty"`
192+
SSHPrivateKeyFile string `yaml:"ssh_private_key_file,omitempty"`
193+
DefaultLimitMode string `yaml:"default_limit_mode,omitempty"`
194+
AskPass bool `yaml:"ask_pass,omitempty"`
195+
AskBecomePass bool `yaml:"ask_become_pass,omitempty"`
196+
ExtraArgs []string `yaml:"extra_args,omitempty"`
197+
Bootstrap AnsibleBootstrapConfig `yaml:"bootstrap,omitempty"`
198+
}
199+
200+
// AnsibleBootstrapConfig holds bootstrap access workflow settings for ansible.
201+
type AnsibleBootstrapConfig struct {
202+
Enabled bool `yaml:"enabled,omitempty"`
203+
Username string `yaml:"username,omitempty"`
204+
Shell string `yaml:"shell,omitempty"`
205+
CreateHome bool `yaml:"create_home,omitempty"`
206+
ExcludeWindowsGuests bool `yaml:"exclude_windows_guests,omitempty"`
207+
SSHPublicKeyFile string `yaml:"ssh_public_key_file,omitempty"`
208+
InstallAuthorizedKey bool `yaml:"install_authorized_key,omitempty"`
209+
SetPassword bool `yaml:"set_password,omitempty"`
210+
Password string `yaml:"password,omitempty"`
211+
GrantSudoNOPASSWD bool `yaml:"grant_sudo_nopasswd,omitempty"`
212+
SudoersFileMode string `yaml:"sudoers_file_mode,omitempty"`
213+
DryRunDefault bool `yaml:"dry_run_default,omitempty"`
214+
Parallelism int `yaml:"parallelism,omitempty"`
215+
Timeout string `yaml:"timeout,omitempty"`
216+
FailFast bool `yaml:"fail_fast,omitempty"`
197217
}
198218

199219
// DefaultKeyBindings returns a KeyBindings struct with the default key mappings.
@@ -332,7 +352,17 @@ func NewConfig() *Config {
332352
Debug: strings.ToLower(os.Getenv("PVETUI_DEBUG")) == trueString,
333353
CacheDir: ExpandHomePath(os.Getenv("PVETUI_CACHE_DIR")),
334354
KeyBindings: DefaultKeyBindings(),
335-
ShowIcons: strings.ToLower(os.Getenv("PVETUI_SHOW_ICONS")) != "false",
355+
Plugins: PluginConfig{
356+
Ansible: AnsiblePluginConfig{
357+
Bootstrap: AnsibleBootstrapConfig{
358+
CreateHome: true,
359+
ExcludeWindowsGuests: true,
360+
InstallAuthorizedKey: true,
361+
DryRunDefault: true,
362+
},
363+
},
364+
},
365+
ShowIcons: strings.ToLower(os.Getenv("PVETUI_SHOW_ICONS")) != "false",
336366
}
337367
// Set default values for Realm and ApiPath only
338368
if config.Realm == "" {
@@ -430,6 +460,23 @@ func (c *Config) MergeWithFile(path string) error {
430460
AskPass *bool `yaml:"ask_pass"`
431461
AskBecomePass *bool `yaml:"ask_become_pass"`
432462
ExtraArgs []string `yaml:"extra_args"`
463+
Bootstrap struct {
464+
Enabled *bool `yaml:"enabled"`
465+
Username string `yaml:"username"`
466+
Shell string `yaml:"shell"`
467+
CreateHome *bool `yaml:"create_home"`
468+
ExcludeWindowsGuests *bool `yaml:"exclude_windows_guests"`
469+
SSHPublicKeyFile string `yaml:"ssh_public_key_file"`
470+
InstallAuthorizedKey *bool `yaml:"install_authorized_key"`
471+
SetPassword *bool `yaml:"set_password"`
472+
Password string `yaml:"password"`
473+
GrantSudoNOPASSWD *bool `yaml:"grant_sudo_nopasswd"`
474+
SudoersFileMode string `yaml:"sudoers_file_mode"`
475+
DryRunDefault *bool `yaml:"dry_run_default"`
476+
Parallelism *int `yaml:"parallelism"`
477+
Timeout string `yaml:"timeout"`
478+
FailFast *bool `yaml:"fail_fast"`
479+
} `yaml:"bootstrap"`
433480
} `yaml:"ansible"`
434481
} `yaml:"plugins"`
435482
ShowIcons *bool `yaml:"show_icons"`
@@ -463,7 +510,13 @@ func (c *Config) MergeWithFile(path string) error {
463510
_, hasGlobalMenuKey = fileConfigRaw.KeyBindings["global_menu"]
464511
}
465512

466-
if !isSOPSEncrypted && detectCleartextSensitive(fileConfig.Profiles, fileConfig.Password, fileConfig.TokenSecret, fileConfig.Plugins.Ansible.DefaultPassword) {
513+
if !isSOPSEncrypted && detectCleartextSensitive(
514+
fileConfig.Profiles,
515+
fileConfig.Password,
516+
fileConfig.TokenSecret,
517+
fileConfig.Plugins.Ansible.DefaultPassword,
518+
fileConfig.Plugins.Ansible.Bootstrap.Password,
519+
) {
467520
c.hasCleartextSensitive = true
468521
}
469522

@@ -715,6 +768,51 @@ func (c *Config) MergeWithFile(path string) error {
715768
if fileConfig.Plugins.Ansible.ExtraArgs != nil {
716769
c.Plugins.Ansible.ExtraArgs = append([]string{}, fileConfig.Plugins.Ansible.ExtraArgs...)
717770
}
771+
if fileConfig.Plugins.Ansible.Bootstrap.Enabled != nil {
772+
c.Plugins.Ansible.Bootstrap.Enabled = *fileConfig.Plugins.Ansible.Bootstrap.Enabled
773+
}
774+
if fileConfig.Plugins.Ansible.Bootstrap.Username != "" {
775+
c.Plugins.Ansible.Bootstrap.Username = fileConfig.Plugins.Ansible.Bootstrap.Username
776+
}
777+
if fileConfig.Plugins.Ansible.Bootstrap.Shell != "" {
778+
c.Plugins.Ansible.Bootstrap.Shell = fileConfig.Plugins.Ansible.Bootstrap.Shell
779+
}
780+
if fileConfig.Plugins.Ansible.Bootstrap.CreateHome != nil {
781+
c.Plugins.Ansible.Bootstrap.CreateHome = *fileConfig.Plugins.Ansible.Bootstrap.CreateHome
782+
}
783+
if fileConfig.Plugins.Ansible.Bootstrap.ExcludeWindowsGuests != nil {
784+
c.Plugins.Ansible.Bootstrap.ExcludeWindowsGuests = *fileConfig.Plugins.Ansible.Bootstrap.ExcludeWindowsGuests
785+
}
786+
if fileConfig.Plugins.Ansible.Bootstrap.SSHPublicKeyFile != "" {
787+
c.Plugins.Ansible.Bootstrap.SSHPublicKeyFile = ExpandHomePath(fileConfig.Plugins.Ansible.Bootstrap.SSHPublicKeyFile)
788+
}
789+
if fileConfig.Plugins.Ansible.Bootstrap.InstallAuthorizedKey != nil {
790+
c.Plugins.Ansible.Bootstrap.InstallAuthorizedKey = *fileConfig.Plugins.Ansible.Bootstrap.InstallAuthorizedKey
791+
}
792+
if fileConfig.Plugins.Ansible.Bootstrap.SetPassword != nil {
793+
c.Plugins.Ansible.Bootstrap.SetPassword = *fileConfig.Plugins.Ansible.Bootstrap.SetPassword
794+
}
795+
if fileConfig.Plugins.Ansible.Bootstrap.Password != "" {
796+
c.Plugins.Ansible.Bootstrap.Password = fileConfig.Plugins.Ansible.Bootstrap.Password
797+
}
798+
if fileConfig.Plugins.Ansible.Bootstrap.GrantSudoNOPASSWD != nil {
799+
c.Plugins.Ansible.Bootstrap.GrantSudoNOPASSWD = *fileConfig.Plugins.Ansible.Bootstrap.GrantSudoNOPASSWD
800+
}
801+
if fileConfig.Plugins.Ansible.Bootstrap.SudoersFileMode != "" {
802+
c.Plugins.Ansible.Bootstrap.SudoersFileMode = fileConfig.Plugins.Ansible.Bootstrap.SudoersFileMode
803+
}
804+
if fileConfig.Plugins.Ansible.Bootstrap.DryRunDefault != nil {
805+
c.Plugins.Ansible.Bootstrap.DryRunDefault = *fileConfig.Plugins.Ansible.Bootstrap.DryRunDefault
806+
}
807+
if fileConfig.Plugins.Ansible.Bootstrap.Parallelism != nil && *fileConfig.Plugins.Ansible.Bootstrap.Parallelism > 0 {
808+
c.Plugins.Ansible.Bootstrap.Parallelism = *fileConfig.Plugins.Ansible.Bootstrap.Parallelism
809+
}
810+
if fileConfig.Plugins.Ansible.Bootstrap.Timeout != "" {
811+
c.Plugins.Ansible.Bootstrap.Timeout = fileConfig.Plugins.Ansible.Bootstrap.Timeout
812+
}
813+
if fileConfig.Plugins.Ansible.Bootstrap.FailFast != nil {
814+
c.Plugins.Ansible.Bootstrap.FailFast = *fileConfig.Plugins.Ansible.Bootstrap.FailFast
815+
}
718816

719817
// Merge show_icons configuration if provided
720818
if fileConfig.ShowIcons != nil {
@@ -750,13 +848,20 @@ func (c *Config) MergeWithFile(path string) error {
750848
return nil
751849
}
752850

753-
func detectCleartextSensitive(profiles map[string]ProfileConfig, legacyPassword, legacyTokenSecret, ansibleDefaultPassword string) bool {
851+
func detectCleartextSensitive(
852+
profiles map[string]ProfileConfig,
853+
legacyPassword,
854+
legacyTokenSecret,
855+
ansibleDefaultPassword,
856+
ansibleBootstrapPassword string,
857+
) bool {
754858
if hasCleartextSensitiveProfiles(profiles) {
755859
return true
756860
}
757861
return hasCleartextSensitiveValue(legacyPassword) ||
758862
hasCleartextSensitiveValue(legacyTokenSecret) ||
759-
hasCleartextSensitiveValue(ansibleDefaultPassword)
863+
hasCleartextSensitiveValue(ansibleDefaultPassword) ||
864+
hasCleartextSensitiveValue(ansibleBootstrapPassword)
760865
}
761866

762867
func hasCleartextSensitiveProfiles(profiles map[string]ProfileConfig) bool {
@@ -1119,6 +1224,24 @@ func (c *Config) SetDefaults() {
11191224
if c.Plugins.Ansible.SSHPrivateKeyFile != "" {
11201225
c.Plugins.Ansible.SSHPrivateKeyFile = ExpandHomePath(c.Plugins.Ansible.SSHPrivateKeyFile)
11211226
}
1227+
if c.Plugins.Ansible.Bootstrap.Username == "" {
1228+
c.Plugins.Ansible.Bootstrap.Username = "ansible"
1229+
}
1230+
if c.Plugins.Ansible.Bootstrap.Shell == "" {
1231+
c.Plugins.Ansible.Bootstrap.Shell = "/bin/bash"
1232+
}
1233+
if c.Plugins.Ansible.Bootstrap.SudoersFileMode == "" {
1234+
c.Plugins.Ansible.Bootstrap.SudoersFileMode = "0440"
1235+
}
1236+
if c.Plugins.Ansible.Bootstrap.Timeout == "" {
1237+
c.Plugins.Ansible.Bootstrap.Timeout = "2m"
1238+
}
1239+
if c.Plugins.Ansible.Bootstrap.Parallelism <= 0 {
1240+
c.Plugins.Ansible.Bootstrap.Parallelism = 10
1241+
}
1242+
if c.Plugins.Ansible.Bootstrap.SSHPublicKeyFile != "" {
1243+
c.Plugins.Ansible.Bootstrap.SSHPublicKeyFile = ExpandHomePath(c.Plugins.Ansible.Bootstrap.SSHPublicKeyFile)
1244+
}
11221245

11231246
// ShowIcons defaults to true (icons enabled)
11241247
// No explicit default needed since it's already set in NewConfig()

internal/config/config.tpl.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,18 @@ plugins:
9191
# ask_pass: false # Add --ask-pass by default
9292
# ask_become_pass: false # Add --ask-become-pass by default
9393
# extra_args: [] # Extra args appended to ansible/ansible-playbook
94+
# bootstrap:
95+
# enabled: false
96+
# username: "ansible"
97+
# shell: "/bin/bash"
98+
# create_home: true
99+
# ssh_public_key_file: "~/.ssh/id_ed25519.pub"
100+
# install_authorized_key: true
101+
# set_password: false
102+
# # password: "secret" # Sensitive field (encrypted with age/SOPS)
103+
# grant_sudo_nopasswd: false
104+
# sudoers_file_mode: "0440"
105+
# dry_run_default: true
106+
# parallelism: 10
107+
# timeout: "2m"
108+
# fail_fast: false

internal/config/config_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,22 @@ plugins:
541541
extra_args:
542542
- --forks
543543
- "20"
544+
bootstrap:
545+
enabled: true
546+
username: ansible
547+
shell: /bin/bash
548+
create_home: true
549+
exclude_windows_guests: true
550+
ssh_public_key_file: ~/.ssh/id_ed25519.pub
551+
install_authorized_key: true
552+
set_password: true
553+
password: bootstrap-secret
554+
grant_sudo_nopasswd: true
555+
sudoers_file_mode: "0440"
556+
dry_run_default: true
557+
parallelism: 12
558+
timeout: 3m
559+
fail_fast: false
544560
`
545561

546562
_, err = tempFile.WriteString(content)
@@ -563,6 +579,46 @@ plugins:
563579
assert.True(t, cfg.Plugins.Ansible.AskPass)
564580
assert.True(t, cfg.Plugins.Ansible.AskBecomePass)
565581
assert.Equal(t, []string{"--forks", "20"}, cfg.Plugins.Ansible.ExtraArgs)
582+
assert.True(t, cfg.Plugins.Ansible.Bootstrap.Enabled)
583+
assert.Equal(t, "ansible", cfg.Plugins.Ansible.Bootstrap.Username)
584+
assert.Equal(t, "/bin/bash", cfg.Plugins.Ansible.Bootstrap.Shell)
585+
assert.True(t, cfg.Plugins.Ansible.Bootstrap.CreateHome)
586+
assert.True(t, cfg.Plugins.Ansible.Bootstrap.ExcludeWindowsGuests)
587+
assert.Contains(t, cfg.Plugins.Ansible.Bootstrap.SSHPublicKeyFile, ".ssh")
588+
assert.True(t, cfg.Plugins.Ansible.Bootstrap.InstallAuthorizedKey)
589+
assert.True(t, cfg.Plugins.Ansible.Bootstrap.SetPassword)
590+
assert.Equal(t, "bootstrap-secret", cfg.Plugins.Ansible.Bootstrap.Password)
591+
assert.True(t, cfg.Plugins.Ansible.Bootstrap.GrantSudoNOPASSWD)
592+
assert.Equal(t, "0440", cfg.Plugins.Ansible.Bootstrap.SudoersFileMode)
593+
assert.True(t, cfg.Plugins.Ansible.Bootstrap.DryRunDefault)
594+
assert.Equal(t, 12, cfg.Plugins.Ansible.Bootstrap.Parallelism)
595+
assert.Equal(t, "3m", cfg.Plugins.Ansible.Bootstrap.Timeout)
596+
assert.False(t, cfg.Plugins.Ansible.Bootstrap.FailFast)
597+
}
598+
599+
func TestConfig_MergeWithFile_AnsibleBootstrapExcludeWindowsGuestsFalse(t *testing.T) {
600+
tempFile, err := os.CreateTemp("", "config-ansible-bootstrap-windows-*.yaml")
601+
require.NoError(t, err)
602+
defer os.Remove(tempFile.Name())
603+
604+
content := `
605+
plugins:
606+
ansible:
607+
bootstrap:
608+
exclude_windows_guests: false
609+
`
610+
611+
_, err = tempFile.WriteString(content)
612+
require.NoError(t, err)
613+
require.NoError(t, tempFile.Close())
614+
615+
cfg := NewConfig()
616+
require.True(t, cfg.Plugins.Ansible.Bootstrap.ExcludeWindowsGuests)
617+
618+
require.NoError(t, cfg.MergeWithFile(tempFile.Name()))
619+
cfg.SetDefaults()
620+
621+
assert.False(t, cfg.Plugins.Ansible.Bootstrap.ExcludeWindowsGuests)
566622
}
567623

568624
func TestConfig_MergeWithEncryptedFile(t *testing.T) {
@@ -614,6 +670,11 @@ func TestConfig_SetDefaults(t *testing.T) {
614670
assert.Equal(t, "yaml", config.Plugins.Ansible.InventoryFormat)
615671
assert.Equal(t, "compact", config.Plugins.Ansible.InventoryStyle)
616672
assert.Equal(t, "selection", config.Plugins.Ansible.DefaultLimitMode)
673+
assert.Equal(t, "ansible", config.Plugins.Ansible.Bootstrap.Username)
674+
assert.Equal(t, "/bin/bash", config.Plugins.Ansible.Bootstrap.Shell)
675+
assert.Equal(t, "0440", config.Plugins.Ansible.Bootstrap.SudoersFileMode)
676+
assert.Equal(t, "2m", config.Plugins.Ansible.Bootstrap.Timeout)
677+
assert.Equal(t, 10, config.Plugins.Ansible.Bootstrap.Parallelism)
617678
assert.Equal(t, "Ctrl+f", config.KeyBindings.AdvancedGuestFilter)
618679
}
619680

@@ -1208,6 +1269,24 @@ func TestMergeWithFileMarksCleartextSensitive(t *testing.T) {
12081269
require.True(t, cfg.HasCleartextSensitiveData(), "expected cleartext detection when passwords are unencrypted")
12091270
}
12101271

1272+
func TestMergeWithFileMarksCleartextSensitiveForBootstrapPassword(t *testing.T) {
1273+
cfg := NewConfig()
1274+
cfg.Profiles = make(map[string]ProfileConfig)
1275+
cfg.DefaultProfile = testDefaultProfile
1276+
1277+
content := `plugins:
1278+
ansible:
1279+
bootstrap:
1280+
set_password: true
1281+
password: plain-bootstrap-secret
1282+
`
1283+
path := filepath.Join(t.TempDir(), "config.yml")
1284+
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
1285+
1286+
require.NoError(t, cfg.MergeWithFile(path))
1287+
require.True(t, cfg.HasCleartextSensitiveData(), "expected cleartext detection for bootstrap password")
1288+
}
1289+
12111290
func TestMergeWithFileSkipsEncryptedSensitive(t *testing.T) {
12121291
cfg := NewConfig()
12131292
cfg.Profiles = make(map[string]ProfileConfig)

internal/config/encryption.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,13 @@ func EncryptConfigSensitiveFields(cfg *Config) error {
275275
return fmt.Errorf("encrypt ansible default_password: %w", err)
276276
}
277277
}
278+
if cfg.Plugins.Ansible.Bootstrap.Password != "" && !isEncrypted(cfg.Plugins.Ansible.Bootstrap.Password) {
279+
var err error
280+
cfg.Plugins.Ansible.Bootstrap.Password, err = EncryptField(cfg.Plugins.Ansible.Bootstrap.Password)
281+
if err != nil {
282+
return fmt.Errorf("encrypt ansible bootstrap password: %w", err)
283+
}
284+
}
278285

279286
return nil
280287
}
@@ -314,6 +321,13 @@ func DecryptConfigSensitiveFields(cfg *Config) error {
314321
return fmt.Errorf("decrypt ansible default_password: %w", err)
315322
}
316323
}
324+
if cfg.Plugins.Ansible.Bootstrap.Password != "" {
325+
var err error
326+
cfg.Plugins.Ansible.Bootstrap.Password, err = DecryptField(cfg.Plugins.Ansible.Bootstrap.Password)
327+
if err != nil {
328+
return fmt.Errorf("decrypt ansible bootstrap password: %w", err)
329+
}
330+
}
317331

318332
return nil
319333
}

internal/plugins/ansible/inventory.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,10 @@ func BuildInventoryWithFormat(nodes []*api.Node, guests []*api.VM, defaults Inve
125125
"ansible_host": target,
126126
"ansible_user": defaults.VMSSHUser,
127127
"pvetui_kind": "guest",
128+
"pvetui_guest_name": guest.Name,
128129
"pvetui_guest_id": fmt.Sprintf("%d", guest.ID),
129130
"pvetui_guest_type": guest.Type,
131+
"pvetui_guest_os": strings.TrimSpace(guest.OSType),
130132
"pvetui_status": guest.Status,
131133
"pvetui_node": guest.Node,
132134
}

0 commit comments

Comments
 (0)