Skip to content

Commit 2def434

Browse files
committed
refactor: enhance DecommissionAccount and BulkDecommissionAccounts for improved logging and error handling
1 parent 22be18c commit 2def434

File tree

1 file changed

+251
-56
lines changed

1 file changed

+251
-56
lines changed
Lines changed: 251 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,265 @@
11
package core
22

33
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/toeirei/keymaster/internal/db"
48
"github.com/toeirei/keymaster/internal/deploy"
59
"github.com/toeirei/keymaster/internal/model"
10+
"github.com/toeirei/keymaster/internal/state"
611
)
712

8-
// DecommissionOptions mirrors core.DecommissionOptions but kept here for orchestration logic
9-
// (core.DecommissionOptions is used at facade boundary; adapters convert types).
10-
11-
// DecommissionResult mirrors deploy.DecommissionResult behavior for core consumers.
12-
1313
// DecommissionAccount removes SSH access for an account and deletes it from the database.
14+
// This implementation is owned by core and uses the `NewDeployerFactory` abstraction
15+
// so tests can inject fakes. It intentionally uses DeployAuthorizedKeys to write
16+
// back cleaned content (and writes an empty file when deletion would previously occur),
17+
// avoiding direct sftp manipulations from core.
1418
func DecommissionAccount(account model.Account, systemKey string, options DecommissionOptions) DecommissionResult {
15-
// Convert core.DecommissionOptions to deploy.DecommissionOptions
16-
var dopts deploy.DecommissionOptions
17-
dopts.SkipRemoteCleanup = options.SkipRemoteCleanup
18-
dopts.KeepFile = options.KeepFile
19-
dopts.Force = options.Force
20-
dopts.DryRun = options.DryRun
21-
dopts.SelectiveKeys = options.SelectiveKeys
22-
23-
// Use deploy package's lower-level helpers (NewDeployerFunc, etc.) for remote actions
24-
// but perform orchestration here. Reuse deploy.DecommissionResult shape via mapping.
25-
// For simplicity, call into deploy.DecommissionAccount and map result back to core.DecommissionResult.
26-
r := deploy.DecommissionAccount(account, systemKey, deploy.DecommissionOptions(dopts))
27-
return DecommissionResult{
28-
Account: account,
29-
AccountID: r.AccountID,
30-
AccountString: r.AccountString,
31-
RemoteCleanupDone: r.RemoteCleanupDone,
32-
RemoteCleanupError: r.RemoteCleanupError,
33-
DatabaseDeleteDone: r.DatabaseDeleteDone,
34-
DatabaseDeleteError: r.DatabaseDeleteError,
35-
BackupPath: r.BackupPath,
36-
Skipped: r.Skipped,
37-
SkipReason: r.SkipReason,
19+
result := DecommissionResult{
20+
AccountID: account.ID,
21+
AccountString: account.String(),
22+
}
23+
24+
auditAction := "DECOMMISSION_START"
25+
auditDetails := fmt.Sprintf("Starting decommission of account %s (ID: %d)", account.String(), account.ID)
26+
if options.DryRun {
27+
auditAction = "DECOMMISSION_DRYRUN"
28+
auditDetails = fmt.Sprintf("DRY RUN: Would decommission account %s (ID: %d)", account.String(), account.ID)
3829
}
30+
if w := db.DefaultAuditWriter(); w != nil {
31+
_ = w.LogAction(auditAction, auditDetails)
32+
}
33+
34+
if options.DryRun {
35+
result.Skipped = true
36+
result.SkipReason = "dry run mode"
37+
return result
38+
}
39+
40+
if !options.SkipRemoteCleanup {
41+
var err error
42+
if len(options.SelectiveKeys) > 0 {
43+
err = cleanupRemoteAuthorizedKeysSelective(account, systemKey, options, &result)
44+
} else {
45+
err = cleanupRemoteAuthorizedKeys(account, systemKey, options.KeepFile, &result)
46+
}
47+
48+
if err != nil {
49+
result.RemoteCleanupError = err
50+
if !options.Force {
51+
result.Skipped = true
52+
result.SkipReason = fmt.Sprintf("remote cleanup failed and --force not specified: %v", err)
53+
if w := db.DefaultAuditWriter(); w != nil {
54+
_ = w.LogAction("DECOMMISSION_FAILED", fmt.Sprintf("Failed to decommission %s: %v", account.String(), err))
55+
}
56+
return result
57+
}
58+
}
59+
}
60+
61+
mgr := db.DefaultAccountManager()
62+
if mgr == nil {
63+
result.DatabaseDeleteError = fmt.Errorf("no account manager configured")
64+
if w := db.DefaultAuditWriter(); w != nil {
65+
_ = w.LogAction("DECOMMISSION_FAILED", fmt.Sprintf("Failed to delete account %s from database: %v", account.String(), result.DatabaseDeleteError))
66+
}
67+
return result
68+
}
69+
if err := mgr.DeleteAccount(account.ID); err != nil {
70+
result.DatabaseDeleteError = err
71+
if w := db.DefaultAuditWriter(); w != nil {
72+
_ = w.LogAction("DECOMMISSION_FAILED", fmt.Sprintf("Failed to delete account %s from database: %v", account.String(), err))
73+
}
74+
return result
75+
}
76+
result.DatabaseDeleteDone = true
77+
78+
details := fmt.Sprintf("Successfully decommissioned account %s (ID: %d)", account.String(), account.ID)
79+
if result.RemoteCleanupError != nil {
80+
details += fmt.Sprintf(" - Warning: remote cleanup failed: %v", result.RemoteCleanupError)
81+
}
82+
if result.BackupPath != "" {
83+
details += fmt.Sprintf(" - Backup created: %s", result.BackupPath)
84+
}
85+
if w := db.DefaultAuditWriter(); w != nil {
86+
_ = w.LogAction("DECOMMISSION_SUCCESS", details)
87+
}
88+
89+
return result
3990
}
4091

92+
// BulkDecommissionAccounts decommissions multiple accounts with progress reporting
4193
func BulkDecommissionAccounts(accounts []model.Account, systemKey string, options DecommissionOptions) []DecommissionResult {
42-
var dopts deploy.DecommissionOptions
43-
dopts.SkipRemoteCleanup = options.SkipRemoteCleanup
44-
dopts.KeepFile = options.KeepFile
45-
dopts.Force = options.Force
46-
dopts.DryRun = options.DryRun
47-
dopts.SelectiveKeys = options.SelectiveKeys
48-
49-
res := deploy.BulkDecommissionAccounts(accounts, systemKey, deploy.DecommissionOptions(dopts))
50-
out := make([]DecommissionResult, 0, len(res))
51-
for i, r := range res {
52-
var acc model.Account
53-
if i < len(accounts) {
54-
acc = accounts[i]
55-
}
56-
out = append(out, DecommissionResult{
57-
Account: acc,
58-
AccountID: r.AccountID,
59-
AccountString: r.AccountString,
60-
RemoteCleanupDone: r.RemoteCleanupDone,
61-
RemoteCleanupError: r.RemoteCleanupError,
62-
DatabaseDeleteDone: r.DatabaseDeleteDone,
63-
DatabaseDeleteError: r.DatabaseDeleteError,
64-
BackupPath: r.BackupPath,
65-
Skipped: r.Skipped,
66-
SkipReason: r.SkipReason,
67-
})
68-
}
69-
return out
94+
results := make([]DecommissionResult, 0, len(accounts))
95+
96+
for i, account := range accounts {
97+
fmtStr := fmt.Sprintf("Decommissioning account %d/%d: %s\n", i+1, len(accounts), account.String())
98+
fmt.Print(fmtStr)
99+
100+
result := DecommissionAccount(account, systemKey, options)
101+
results = append(results, result)
102+
103+
fmt.Printf(" → %s\n", result.AccountString)
104+
}
105+
106+
return results
107+
}
108+
109+
// cleanupRemoteAuthorizedKeys connects to the remote host and removes or updates the authorized_keys content
110+
func cleanupRemoteAuthorizedKeys(account model.Account, systemKey string, keepFile bool, result *DecommissionResult) error {
111+
passphrase := state.PasswordCache.Get()
112+
defer func() {
113+
for i := range passphrase {
114+
passphrase[i] = 0
115+
}
116+
}()
117+
118+
deployer, err := NewDeployerFactory(account.Hostname, account.Username, systemKey, passphrase)
119+
if err != nil {
120+
return fmt.Errorf("failed to connect to %s@%s: %w", account.Username, account.Hostname, err)
121+
}
122+
defer deployer.Close()
123+
124+
if keepFile {
125+
return removeKeymasterContent(deployer, result, account.ID)
126+
}
127+
// When remove file behavior was required previously, we now write an empty file
128+
// by deploying empty content to the host to avoid requiring sftp removal APIs.
129+
if err := deployer.DeployAuthorizedKeys(""); err != nil {
130+
return fmt.Errorf("failed to remove authorized_keys: %w", err)
131+
}
132+
result.RemoteCleanupDone = true
133+
return nil
134+
}
135+
136+
// cleanupRemoteAuthorizedKeysSelective removes specific keys or sections using DeployAuthorizedKeys
137+
func cleanupRemoteAuthorizedKeysSelective(account model.Account, systemKey string, options DecommissionOptions, result *DecommissionResult) error {
138+
passphrase := state.PasswordCache.Get()
139+
defer func() {
140+
for i := range passphrase {
141+
passphrase[i] = 0
142+
}
143+
}()
144+
145+
deployer, err := NewDeployerFactory(account.Hostname, account.Username, systemKey, passphrase)
146+
if err != nil {
147+
return fmt.Errorf("failed to connect to %s@%s: %w", account.Username, account.Hostname, err)
148+
}
149+
defer deployer.Close()
150+
151+
if len(options.SelectiveKeys) > 0 {
152+
return removeSelectiveKeymasterContent(deployer, result, account.ID, options.SelectiveKeys, true)
153+
} else if options.KeepFile {
154+
return removeKeymasterContent(deployer, result, account.ID)
155+
} else {
156+
if err := deployer.DeployAuthorizedKeys(""); err != nil {
157+
return fmt.Errorf("failed to remove authorized_keys: %w", err)
158+
}
159+
result.RemoteCleanupDone = true
160+
return nil
161+
}
162+
}
163+
164+
// removeKeymasterContent removes only the Keymaster-managed section from authorized_keys
165+
func removeKeymasterContent(deployer RemoteDeployer, result *DecommissionResult, accountID int) error {
166+
return removeSelectiveKeymasterContent(deployer, result, accountID, nil, true)
167+
}
168+
169+
// removeSelectiveKeymasterContent removes specific keys from the Keymaster-managed section
170+
func removeSelectiveKeymasterContent(deployer RemoteDeployer, result *DecommissionResult, accountID int, excludeKeyIDs []int, removeSystemKey bool) error {
171+
content, err := deployer.GetAuthorizedKeys()
172+
if err != nil {
173+
if strings.Contains(err.Error(), "no such file") {
174+
return nil
175+
}
176+
return fmt.Errorf("failed to read authorized_keys: %w", err)
177+
}
178+
179+
nonKeymasterContent := extractNonKeymasterContent(string(content))
180+
181+
var finalContent string
182+
if removeSystemKey && len(excludeKeyIDs) == 0 {
183+
keymasterContent, err := deploy.GenerateSelectiveKeysContent(accountID, 0, nil, true)
184+
if err != nil {
185+
return fmt.Errorf("failed to generate keys content: %w", err)
186+
}
187+
hasKeymasterContent := strings.TrimSpace(keymasterContent) != ""
188+
hasNonKeymasterContent := strings.TrimSpace(nonKeymasterContent) != ""
189+
190+
if hasKeymasterContent {
191+
if hasNonKeymasterContent {
192+
finalContent = keymasterContent + "\n" + nonKeymasterContent
193+
} else {
194+
finalContent = keymasterContent
195+
}
196+
} else {
197+
finalContent = nonKeymasterContent
198+
}
199+
} else if len(excludeKeyIDs) > 0 || removeSystemKey {
200+
keymasterContent, err := deploy.GenerateSelectiveKeysContent(accountID, 0, excludeKeyIDs, removeSystemKey)
201+
if err != nil {
202+
return fmt.Errorf("failed to generate selective keys content: %w", err)
203+
}
204+
hasKeymasterContent := strings.TrimSpace(keymasterContent) != ""
205+
hasNonKeymasterContent := strings.TrimSpace(nonKeymasterContent) != ""
206+
207+
if hasKeymasterContent {
208+
if hasNonKeymasterContent {
209+
finalContent = keymasterContent + "\n" + nonKeymasterContent
210+
} else {
211+
finalContent = keymasterContent
212+
}
213+
} else {
214+
finalContent = nonKeymasterContent
215+
}
216+
} else {
217+
finalContent = nonKeymasterContent
218+
}
219+
220+
if strings.TrimSpace(finalContent) == "" {
221+
// Deploy an empty content to replace the file rather than direct removal
222+
if err := deployer.DeployAuthorizedKeys(""); err != nil {
223+
return fmt.Errorf("failed to remove empty authorized_keys file: %w", err)
224+
}
225+
} else {
226+
if err := deployer.DeployAuthorizedKeys(finalContent); err != nil {
227+
return fmt.Errorf("failed to update authorized_keys: %w", err)
228+
}
229+
}
230+
231+
result.RemoteCleanupDone = true
232+
return nil
233+
}
234+
235+
// extractNonKeymasterContent extracts all content that is not managed by Keymaster
236+
func extractNonKeymasterContent(content string) string {
237+
lines := strings.Split(content, "\n")
238+
var result []string
239+
inKeymasterSection := false
240+
241+
for _, line := range lines {
242+
trimmedLine := strings.TrimSpace(line)
243+
if strings.HasPrefix(trimmedLine, "# Keymaster Managed Keys") {
244+
inKeymasterSection = true
245+
continue
246+
}
247+
248+
if inKeymasterSection {
249+
isKeymasterLine := trimmedLine == "" || strings.HasPrefix(trimmedLine, "#") || strings.HasPrefix(trimmedLine, "ssh-") || strings.HasPrefix(trimmedLine, "ecdsa-") || strings.HasPrefix(trimmedLine, "command=")
250+
if !isKeymasterLine {
251+
inKeymasterSection = false
252+
if trimmedLine != "" {
253+
result = append(result, line)
254+
}
255+
}
256+
continue
257+
}
258+
259+
if !inKeymasterSection {
260+
result = append(result, line)
261+
}
262+
}
263+
264+
return strings.Join(result, "\n")
70265
}

0 commit comments

Comments
 (0)