feat: add diagnostics and safe reset maintenance
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
// MutationLock serializes all state-changing operations across processes.
|
||||
type MutationLock interface {
|
||||
Lock(context.Context) (unlock func(), err error)
|
||||
}
|
||||
|
||||
// LogEntry is the privacy-safe operation record written to bounded local logs.
|
||||
type LogEntry struct {
|
||||
Time time.Time
|
||||
Operation string
|
||||
Target string
|
||||
Result string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type OperationLogger interface{ Write(LogEntry) error }
|
||||
|
||||
type Maintenance struct {
|
||||
state profile.State
|
||||
targets []target.Target
|
||||
lock MutationLock
|
||||
logger OperationLogger
|
||||
}
|
||||
|
||||
func NewMaintenance(state profile.State, targets []target.Target, lock MutationLock, logger OperationLogger) *Maintenance {
|
||||
return &Maintenance{state: state, targets: append([]target.Target(nil), targets...), lock: lock, logger: logger}
|
||||
}
|
||||
|
||||
type Diagnostic struct {
|
||||
TargetID string
|
||||
Check target.Check
|
||||
}
|
||||
|
||||
type DoctorReport struct{ Diagnostics []Diagnostic }
|
||||
|
||||
func (r DoctorReport) Healthy() bool {
|
||||
for _, diagnostic := range r.Diagnostics {
|
||||
if !diagnostic.Check.OK {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (r DoctorReport) String() string {
|
||||
var output strings.Builder
|
||||
for _, diagnostic := range r.Diagnostics {
|
||||
status := "OK"
|
||||
if !diagnostic.Check.OK {
|
||||
status = "FAIL"
|
||||
}
|
||||
name := diagnostic.Check.Name
|
||||
if diagnostic.TargetID != "" {
|
||||
name = diagnostic.TargetID + ": " + name
|
||||
}
|
||||
fmt.Fprintf(&output, "[%s] %s — %s\n", status, name, profile.RedactText(diagnostic.Check.Evidence))
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// Doctor only invokes observational Target methods.
|
||||
func (m *Maintenance) Doctor(ctx context.Context, applicationChecks []target.Check) DoctorReport {
|
||||
report := DoctorReport{}
|
||||
for _, check := range applicationChecks {
|
||||
check.Evidence = profile.RedactText(check.Evidence)
|
||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{Check: check})
|
||||
}
|
||||
active := activeFromState(m.state)
|
||||
for _, adapter := range m.targets {
|
||||
availability := adapter.Availability(ctx)
|
||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "Target availability", OK: availability.Available, Evidence: availability.Evidence}})
|
||||
inspection := adapter.Inspect(ctx, active)
|
||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "detected state", OK: inspection.State != target.Drifted, Evidence: string(inspection.State) + ": " + inspection.Evidence}})
|
||||
if diagnosable, ok := adapter.(target.Diagnosable); ok {
|
||||
for _, check := range diagnosable.DiagnosticChecks(ctx, active) {
|
||||
check.Evidence = profile.RedactText(check.Evidence)
|
||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: check})
|
||||
}
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
type ResetTargetResult struct {
|
||||
TargetID string
|
||||
Inspection target.Inspection
|
||||
Skipped bool
|
||||
Err error
|
||||
}
|
||||
type ResetResult struct {
|
||||
Cancelled bool
|
||||
Targets []ResetTargetResult
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *Maintenance) Reset(ctx context.Context, confirmed bool) ResetResult {
|
||||
if !confirmed {
|
||||
return ResetResult{Cancelled: true}
|
||||
}
|
||||
if m.lock == nil {
|
||||
return ResetResult{Err: errors.New("mutation lock is not configured")}
|
||||
}
|
||||
unlock, err := m.lock.Lock(ctx)
|
||||
if err != nil {
|
||||
return ResetResult{Err: fmt.Errorf("lock proxyctl mutation: %w", err)}
|
||||
}
|
||||
defer unlock()
|
||||
active := activeFromState(m.state)
|
||||
result := ResetResult{}
|
||||
for _, adapter := range m.targets {
|
||||
inspection := adapter.Inspect(ctx, active)
|
||||
if inspection.State != target.Enabled && inspection.State != target.Drifted {
|
||||
result.Targets = append(result.Targets, ResetTargetResult{TargetID: adapter.ID(), Inspection: inspection, Skipped: true})
|
||||
continue
|
||||
}
|
||||
operation := adapter.SetDesired(ctx, false, active)
|
||||
detail, outcome := operation.Inspection.Evidence, "success"
|
||||
if operation.Err != nil {
|
||||
outcome = "failure"
|
||||
detail = operation.Err.Error()
|
||||
result.Err = errors.Join(result.Err, fmt.Errorf("reset %s Target: %w", adapter.ID(), redactError(operation.Err)))
|
||||
}
|
||||
detail = RedactLogDetail(detail)
|
||||
if m.logger != nil {
|
||||
_ = m.logger.Write(LogEntry{Time: time.Now(), Operation: "reset", Target: adapter.ID(), Result: outcome, Detail: detail})
|
||||
}
|
||||
result.Targets = append(result.Targets, ResetTargetResult{TargetID: adapter.ID(), Inspection: operation.Inspection, Err: redactError(operation.Err)})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func activeFromState(state profile.State) *profile.Profile {
|
||||
for i := range state.Profiles {
|
||||
if state.Profiles[i].Name == state.ActiveProfile {
|
||||
value := state.Profiles[i]
|
||||
return &value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var authenticatedURL = regexp.MustCompile(`(?i)(?:https?|socks5h?)://[^\s'\"<>]*@[^\s'\"<>]+`)
|
||||
|
||||
// RedactLogDetail omits complete authenticated URLs, not just their passwords.
|
||||
func RedactLogDetail(value string) string {
|
||||
value = authenticatedURL.ReplaceAllString(value, "[REDACTED AUTHENTICATED URL]")
|
||||
return profile.RedactText(value)
|
||||
}
|
||||
func redactError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New(profile.RedactText(err.Error()))
|
||||
}
|
||||
Reference in New Issue
Block a user