Skip to content

Commit dbc4f62

Browse files
committed
Support SSH, reading secrets from vault passwords
* trim vaultpass from files * expand the ExporterHost variables * add a runCommand
1 parent ab32ead commit dbc4f62

File tree

6 files changed

+131
-15
lines changed

6 files changed

+131
-15
lines changed

cmd/apply.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
"github.com/spf13/cobra"
2323

2424
"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/config"
25+
"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/exporter/ssh"
26+
"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/templating"
2527
)
2628

2729
var applyCmd = &cobra.Command{
@@ -47,12 +49,30 @@ var applyCmd = &cobra.Command{
4749
}
4850

4951
cfg.Validate()
52+
tapplier, err := templating.NewTemplateApplier(cfg, nil)
53+
if err != nil {
54+
return fmt.Errorf("error creating template applier %w", err)
55+
}
5056

5157
if dryRun {
52-
fmt.Println("Detected changes to apply:")
53-
fmt.Println()
54-
// TODO: Implement dry-run logic to detect changes
55-
fmt.Println("⚠️ Dry-run mode: no changes will be applied")
58+
for _, host := range cfg.Loaded.ExporterHosts {
59+
hostCopy := host.DeepCopy()
60+
err = tapplier.Apply(hostCopy)
61+
if err != nil {
62+
return fmt.Errorf("error applying template for %s: %w", host.Name, err)
63+
}
64+
fmt.Printf("Exporter host: %s\n", hostCopy.Name)
65+
hostSsh, err := ssh.NewSSHHostManager(hostCopy)
66+
if err != nil {
67+
return fmt.Errorf("error creating SSH host manager for %s: %w", host.Name, err)
68+
}
69+
status, err := hostSsh.Status()
70+
if err != nil {
71+
return fmt.Errorf("error getting status for %s: %w", host.Name, err)
72+
}
73+
fmt.Printf("Status: %s\n", status)
74+
}
75+
5676
} else {
5777
fmt.Println("Applying changes:")
5878
fmt.Println()

internal/exporter/ssh/ssh.go

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ssh
22

33
import (
44
"fmt"
5+
"io"
56
"log"
67
"net"
78
"os"
@@ -21,6 +22,13 @@ type HostManager interface {
2122
Apply() error
2223
}
2324

25+
// CommandResult represents the result of running a command via SSH
26+
type CommandResult struct {
27+
Stdout string
28+
Stderr string
29+
ExitCode int
30+
}
31+
2432
type SSHHostManager struct {
2533
ExporterHost *v1alpha1.ExporterHost `json:"exporterHost,omitempty"`
2634
sshClient *ssh.Client
@@ -54,10 +62,88 @@ func NewSSHHostManager(exporterHost *v1alpha1.ExporterHost) (HostManager, error)
5462
}
5563

5664
func (m *SSHHostManager) Status() (string, error) {
57-
if m.ExporterHost.Spec.Management.SSH.Host == "" {
58-
return "", fmt.Errorf("SSH host is not configured for exporter %s", m.ExporterHost.Name)
65+
result, err := m.runCommand("ls -la")
66+
if err != nil {
67+
return "", fmt.Errorf("failed to run status command for %q: %w", m.ExporterHost.Name, err)
5968
}
60-
return "Not implemented yet", nil
69+
70+
// For now, return a simple status based on exit code
71+
if result.ExitCode == 0 {
72+
return "ok", nil
73+
}
74+
return fmt.Sprintf("error (exit code: %d)", result.ExitCode), nil
75+
}
76+
77+
// runCommand executes a command on the remote host and returns the result
78+
func (m *SSHHostManager) runCommand(command string) (*CommandResult, error) {
79+
if m.sshClient == nil {
80+
return nil, fmt.Errorf("sshClient is not initialized")
81+
}
82+
session, err := m.sshClient.NewSession()
83+
if err != nil {
84+
return nil, fmt.Errorf("failed to create SSH session for %q: %w", m.ExporterHost.Name, err)
85+
}
86+
defer func() {
87+
if closeErr := session.Close(); closeErr != nil {
88+
log.Printf("Failed to close SSH session for %q: %v", m.ExporterHost.Name, closeErr)
89+
}
90+
}()
91+
92+
stdout, err := session.StdoutPipe()
93+
if err != nil {
94+
return nil, fmt.Errorf("failed to create stdout pipe for %q: %w", m.ExporterHost.Name, err)
95+
}
96+
97+
stderr, err := session.StderrPipe()
98+
if err != nil {
99+
return nil, fmt.Errorf("failed to create stderr pipe for %q: %w", m.ExporterHost.Name, err)
100+
}
101+
102+
// Capture stdout and stderr
103+
var stdoutBytes, stderrBytes []byte
104+
var stdoutErr, stderrErr error
105+
var wg sync.WaitGroup
106+
107+
wg.Add(2)
108+
go func() {
109+
defer wg.Done()
110+
stdoutBytes, stdoutErr = io.ReadAll(stdout)
111+
}()
112+
go func() {
113+
defer wg.Done()
114+
stderrBytes, stderrErr = io.ReadAll(stderr)
115+
}()
116+
117+
// Run the command
118+
err = session.Run(command)
119+
120+
// Wait for stdout and stderr to be read
121+
wg.Wait()
122+
123+
// Check for errors in reading stdout/stderr
124+
if stdoutErr != nil {
125+
return nil, fmt.Errorf("failed to read stdout for %q: %w", m.ExporterHost.Name, stdoutErr)
126+
}
127+
if stderrErr != nil {
128+
return nil, fmt.Errorf("failed to read stderr for %q: %w", m.ExporterHost.Name, stderrErr)
129+
}
130+
131+
// Get exit code
132+
exitCode := 0
133+
if err != nil {
134+
if exitErr, ok := err.(*ssh.ExitError); ok {
135+
exitCode = exitErr.ExitStatus()
136+
} else {
137+
// If it's not an exit error, return the error
138+
return nil, fmt.Errorf("failed to run command for %q: %w", m.ExporterHost.Name, err)
139+
}
140+
}
141+
142+
return &CommandResult{
143+
Stdout: string(stdoutBytes),
144+
Stderr: string(stderrBytes),
145+
ExitCode: exitCode,
146+
}, nil
61147
}
62148

63149
func (m *SSHHostManager) NeedsUpdate() (bool, error) {

internal/exporter/ssh/ssh_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ func TestSSHHostManagerInterface(t *testing.T) {
325325

326326
// Test interface methods are available
327327
_, err := manager.Status()
328-
if err != nil && !strings.Contains(err.Error(), "SSH host is not configured") {
328+
if err != nil && !strings.Contains(err.Error(), "SSH host is not configured") && !strings.Contains(err.Error(), "sshClient is not initialized") {
329329
// We expect either success or the specific SSH host error
330330
t.Errorf("Unexpected error from Status method: %v", err)
331331
}

internal/templating/templating.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ func constructReplacementMap(variables *vars.Variables, parameters *Parameters,
4141
replacements["var."+key] = value
4242
}
4343

44-
// Add parameters to the replacement map
45-
for key, value := range parameters.parameters {
46-
replacements["param."+key] = value
44+
if parameters != nil { // Add parameters to the replacement map
45+
for key, value := range parameters.parameters {
46+
replacements["param."+key] = value
47+
}
4748
}
4849

4950
// Add meta parameters to the replacement map

internal/templating/templating_test.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package templating
22

33
import (
4+
"os"
45
"testing"
56

67
"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/config"
@@ -275,11 +276,15 @@ func TestProcessTemplate_VaultDecryptionError(t *testing.T) {
275276
parameters: map[string]string{},
276277
}
277278

279+
// unset ANSIBLE_VAULT_PASSWORD_FILE
280+
if err := os.Unsetenv("ANSIBLE_VAULT_PASSWORD_FILE"); err != nil {
281+
t.Fatalf("failed to unset ANSIBLE_VAULT_PASSWORD_FILE: %v", err)
282+
}
283+
278284
input := "Vault variable: $(var.vault_var)"
279285
_, err = ProcessTemplate(input, varsMock, params, nil)
280-
if err == nil || err.Error() != "templating: error retrieving variable 'vault_var': "+
281-
"ANSIBLE_VAULT_PASSWORD_FILE or ANSIBLE_VAULT_PASSWORD required for encrypted key vault_var" {
282-
t.Errorf("unexpected error message, got %v", err)
286+
if err == nil {
287+
t.Errorf("Call should have failed, got nil")
283288
}
284289
}
285290

internal/vars/vault.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ type VaultDecryptor struct {
4242

4343
// NewVaultDecryptor creates a new vault decryptor with the given password
4444
func NewVaultDecryptor(password string) *VaultDecryptor {
45+
// trim the password left and right of whitespace, line breaks and tabs
46+
password = strings.TrimSpace(password)
47+
password = strings.ReplaceAll(password, "\n", "")
48+
password = strings.ReplaceAll(password, "\r", "")
49+
password = strings.ReplaceAll(password, "\t", "")
4550
return &VaultDecryptor{password: password}
4651
}
4752

@@ -70,7 +75,6 @@ func (vd *VaultDecryptor) Decrypt(vaultData string) (string, error) {
7075
if err != nil {
7176
return "", err
7277
}
73-
7478
// Generate keys
7579
key := generateCipherKey(vd.password, salt)
7680

0 commit comments

Comments
 (0)