forked from Gitlink/gitlink-cli
feat(common): add guarded operation executor
This commit is contained in:
parent
c1f156f7df
commit
c9ae4c70b8
|
|
@ -0,0 +1,214 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RiskLevel string
|
||||
|
||||
const (
|
||||
RiskLow RiskLevel = "low"
|
||||
RiskMedium RiskLevel = "medium"
|
||||
RiskHigh RiskLevel = "high"
|
||||
)
|
||||
|
||||
const redactedValue = "<redacted>"
|
||||
|
||||
var ErrConfirmationRequired = errors.New("explicit confirmation is required")
|
||||
|
||||
type ExitCoder interface {
|
||||
ExitCode() int
|
||||
}
|
||||
|
||||
type ConfirmationError struct {
|
||||
Operation string
|
||||
Risk RiskLevel
|
||||
}
|
||||
|
||||
func (e *ConfirmationError) Error() string {
|
||||
return fmt.Sprintf("%s: %v for %s-risk operation", e.Operation, ErrConfirmationRequired, e.Risk)
|
||||
}
|
||||
|
||||
func (e *ConfirmationError) Unwrap() error { return ErrConfirmationRequired }
|
||||
func (e *ConfirmationError) ExitCode() int { return 2 }
|
||||
|
||||
type GuardedOperation struct {
|
||||
Name string
|
||||
Risk RiskLevel
|
||||
DryRun bool
|
||||
Confirmed bool
|
||||
RequireConfirmation bool
|
||||
Preview interface{}
|
||||
SensitiveFields []string
|
||||
}
|
||||
|
||||
type GuardedOperationResult struct {
|
||||
Operation string `json:"operation"`
|
||||
Risk RiskLevel `json:"risk"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Executed bool `json:"executed"`
|
||||
Preview interface{} `json:"preview,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (op GuardedOperation) Execute(run func() (interface{}, error)) (*GuardedOperationResult, error) {
|
||||
op = normalizeGuardedOperation(op)
|
||||
result := &GuardedOperationResult{
|
||||
Operation: op.Name,
|
||||
Risk: op.Risk,
|
||||
DryRun: op.DryRun,
|
||||
Preview: RedactSensitive(op.Preview, op.SensitiveFields),
|
||||
}
|
||||
if op.DryRun {
|
||||
return result, nil
|
||||
}
|
||||
if (op.RequireConfirmation || op.Risk == RiskHigh) && !op.Confirmed {
|
||||
return result, &ConfirmationError{Operation: op.Name, Risk: op.Risk}
|
||||
}
|
||||
data, err := run()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Executed = true
|
||||
result.Data = RedactSensitive(data, op.SensitiveFields)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type BatchOperation struct {
|
||||
ID string
|
||||
Preview interface{}
|
||||
Run func() (interface{}, error)
|
||||
}
|
||||
|
||||
type BatchOptions struct {
|
||||
Guard GuardedOperation
|
||||
ContinueOnError bool
|
||||
}
|
||||
|
||||
type BatchItemResult struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Preview interface{} `json:"preview,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type BatchResult struct {
|
||||
Operation string `json:"operation"`
|
||||
Risk RiskLevel `json:"risk"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Items []BatchItemResult `json:"items"`
|
||||
}
|
||||
|
||||
type BatchError struct {
|
||||
Failed int
|
||||
Total int
|
||||
}
|
||||
|
||||
func (e *BatchError) Error() string {
|
||||
return fmt.Sprintf("batch operation failed for %d of %d items", e.Failed, e.Total)
|
||||
}
|
||||
func (e *BatchError) ExitCode() int { return 3 }
|
||||
|
||||
func ExecuteBatch(operations []BatchOperation, options BatchOptions) (*BatchResult, error) {
|
||||
guard := normalizeGuardedOperation(options.Guard)
|
||||
result := &BatchResult{
|
||||
Operation: guard.Name,
|
||||
Risk: guard.Risk,
|
||||
DryRun: guard.DryRun,
|
||||
Total: len(operations),
|
||||
Items: make([]BatchItemResult, 0, len(operations)),
|
||||
}
|
||||
if !guard.DryRun && (guard.RequireConfirmation || guard.Risk == RiskHigh) && !guard.Confirmed {
|
||||
result.Skipped = len(operations)
|
||||
return result, &ConfirmationError{Operation: guard.Name, Risk: guard.Risk}
|
||||
}
|
||||
|
||||
for index, operation := range operations {
|
||||
item := BatchItemResult{
|
||||
ID: operation.ID,
|
||||
Preview: RedactSensitive(operation.Preview, guard.SensitiveFields),
|
||||
}
|
||||
if guard.DryRun {
|
||||
item.Status = "skipped"
|
||||
result.Skipped++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
data, err := operation.Run()
|
||||
if err != nil {
|
||||
item.Status = "failed"
|
||||
item.Error = err.Error()
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
if !options.ContinueOnError {
|
||||
remaining := len(operations) - index - 1
|
||||
result.Skipped += remaining
|
||||
for _, skipped := range operations[index+1:] {
|
||||
result.Items = append(result.Items, BatchItemResult{ID: skipped.ID, Status: "skipped", Preview: RedactSensitive(skipped.Preview, guard.SensitiveFields)})
|
||||
}
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
item.Status = "succeeded"
|
||||
item.Data = RedactSensitive(data, guard.SensitiveFields)
|
||||
result.Succeeded++
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
if result.Failed > 0 {
|
||||
return result, &BatchError{Failed: result.Failed, Total: result.Total}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeGuardedOperation(op GuardedOperation) GuardedOperation {
|
||||
if op.Name == "" {
|
||||
op.Name = "operation"
|
||||
}
|
||||
if op.Risk == "" {
|
||||
op.Risk = RiskMedium
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
func RedactSensitive(value interface{}, extraFields []string) interface{} {
|
||||
sensitive := map[string]struct{}{
|
||||
"token": {}, "access_token": {}, "authorization": {}, "password": {},
|
||||
"secret": {}, "api_key": {}, "private_key": {}, "cookie": {},
|
||||
}
|
||||
for _, field := range extraFields {
|
||||
sensitive[strings.ToLower(strings.TrimSpace(field))] = struct{}{}
|
||||
}
|
||||
return redactValue(value, sensitive)
|
||||
}
|
||||
|
||||
func redactValue(value interface{}, sensitive map[string]struct{}) interface{} {
|
||||
switch typed := value.(type) {
|
||||
case map[string]interface{}:
|
||||
redacted := make(map[string]interface{}, len(typed))
|
||||
for key, child := range typed {
|
||||
if _, ok := sensitive[strings.ToLower(key)]; ok {
|
||||
redacted[key] = redactedValue
|
||||
} else {
|
||||
redacted[key] = redactValue(child, sensitive)
|
||||
}
|
||||
}
|
||||
return redacted
|
||||
case []interface{}:
|
||||
redacted := make([]interface{}, len(typed))
|
||||
for index, child := range typed {
|
||||
redacted[index] = redactValue(child, sensitive)
|
||||
}
|
||||
return redacted
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGuardedOperationDryRunDoesNotExecuteAndRedacts(t *testing.T) {
|
||||
executed := false
|
||||
result, err := (GuardedOperation{
|
||||
Name: "delete", Risk: RiskHigh, DryRun: true,
|
||||
Preview: map[string]interface{}{"id": 1, "token": "secret"},
|
||||
}).Execute(func() (interface{}, error) {
|
||||
executed = true
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil || executed || result.Executed {
|
||||
t.Fatalf("result=%+v executed=%v err=%v", result, executed, err)
|
||||
}
|
||||
if result.Preview.(map[string]interface{})["token"] != redactedValue {
|
||||
t.Fatal("sensitive preview was not redacted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedOperationRequiresExplicitConfirmation(t *testing.T) {
|
||||
result, err := (GuardedOperation{Name: "delete", Risk: RiskHigh}).Execute(func() (interface{}, error) {
|
||||
return nil, nil
|
||||
})
|
||||
if err == nil || !errors.Is(err, ErrConfirmationRequired) || result.Executed {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
var exitCoder ExitCoder
|
||||
if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != 2 {
|
||||
t.Fatalf("confirmation exit code = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedOperationExecutesConfirmedWrite(t *testing.T) {
|
||||
result, err := (GuardedOperation{Name: "delete", Risk: RiskHigh, Confirmed: true}).Execute(func() (interface{}, error) {
|
||||
return map[string]interface{}{"deleted": true}, nil
|
||||
})
|
||||
if err != nil || !result.Executed || result.Data.(map[string]interface{})["deleted"] != true {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteBatchContinueOnErrorSummarizesPartialSuccess(t *testing.T) {
|
||||
operations := []BatchOperation{
|
||||
{ID: "1", Run: func() (interface{}, error) { return map[string]interface{}{"password": "secret"}, nil }},
|
||||
{ID: "2", Run: func() (interface{}, error) { return nil, errors.New("failed") }},
|
||||
{ID: "3", Run: func() (interface{}, error) { return "ok", nil }},
|
||||
}
|
||||
result, err := ExecuteBatch(operations, BatchOptions{
|
||||
Guard: GuardedOperation{Name: "batch", Risk: RiskMedium}, ContinueOnError: true,
|
||||
})
|
||||
if err == nil || result.Succeeded != 2 || result.Failed != 1 || result.Skipped != 0 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
if result.Items[0].Data.(map[string]interface{})["password"] != redactedValue {
|
||||
t.Fatal("batch result was not redacted")
|
||||
}
|
||||
var exitCoder ExitCoder
|
||||
if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != 3 {
|
||||
t.Fatalf("batch exit code = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteBatchStopsAndMarksRemainingItemsSkipped(t *testing.T) {
|
||||
executedThird := false
|
||||
operations := []BatchOperation{
|
||||
{ID: "1", Run: func() (interface{}, error) { return nil, errors.New("failed") }},
|
||||
{ID: "2", Run: func() (interface{}, error) { executedThird = true; return nil, nil }},
|
||||
}
|
||||
result, err := ExecuteBatch(operations, BatchOptions{Guard: GuardedOperation{Name: "batch"}})
|
||||
if err == nil || result.Failed != 1 || result.Skipped != 1 || executedThird {
|
||||
t.Fatalf("result=%+v executed=%v err=%v", result, executedThird, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteBatchDryRunSkipsAllItems(t *testing.T) {
|
||||
result, err := ExecuteBatch([]BatchOperation{{ID: "1"}, {ID: "2"}}, BatchOptions{
|
||||
Guard: GuardedOperation{Name: "batch", DryRun: true},
|
||||
})
|
||||
if err != nil || result.Skipped != 2 || result.Succeeded != 0 || result.Failed != 0 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue