fix(agent): 审计写入失败不再静默
审计日志是证据而不是控制手段:写失败不能让请求失败,否则磁盘写满会变成一次远程 拒绝服务——已经执行成功的指令会因为日志追加不上而开始报错,那是坏错了地方。 但它也不能消失。静默丢审计条目是那种"运维以为自己有记录、其实没有"的失败,而这份 日志的全部意义就是事后能回答"谁在什么时候要求了什么"。现在写失败会进 agent 自己的 日志,磁盘写满表现为一行反复出现,而不是一片空白。 顺带把 Close 之后的语义写清楚:Close 会丢掉 sink,此后的写入返回 nil——这是刻意的, 因为一个正在关闭的进程与在途请求赛跑时,为每一个恰好到达的请求报一次失败是噪声 而不是信息。这条差别在 agent 日志里看得见。 测试用真实失败路径(写一个已关闭的文件)而不是填满文件系统,并断言 exec.record 把 它报了出来。 Go 全包、Node 112 项、Swift 101 项通过。
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -544,3 +546,51 @@ func TestExecutorRefusesToStartWithoutAPolicyOrPort(t *testing.T) {
|
||||
t.Fatal("port 0 was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// A failed audit write must be visible somewhere. The entry cannot fail the request — the audit log is
|
||||
// evidence, not a control, and a full disk must not become a remote denial of service — but silently
|
||||
// losing entries is the failure mode where an operator believes they have a record and does not.
|
||||
func TestAFailedAuditWriteIsReported(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "audit.log")
|
||||
audit, err := OpenAudit(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Replace the sink with one that fails, which is what a full disk looks like from here. Reaching
|
||||
// into the struct is deliberate: the alternative is filling a filesystem in a test.
|
||||
audit.mu.Lock()
|
||||
audit.sink = nil
|
||||
audit.mu.Unlock()
|
||||
|
||||
var captured bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&captured, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer slog.SetDefault(previous)
|
||||
|
||||
// Close() drops the sink, so a write after it returns nil rather than an error — that is the
|
||||
// documented shutdown case. Force the error path by giving it a closed file instead.
|
||||
file, err := os.OpenFile(filepath.Join(directory, "closed.log"), os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
audit.mu.Lock()
|
||||
audit.sink = file
|
||||
audit.mu.Unlock()
|
||||
|
||||
if err := audit.Write(Entry{Node: "nd_x", Req: "r1", Outcome: "ok"}); err == nil {
|
||||
t.Fatal("writing to a closed file reported success")
|
||||
}
|
||||
// And the executor's own path reports it rather than swallowing it.
|
||||
exec, err := NewExecutor(testPolicy(t), Config{Port: 3000, Audit: audit})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec.record(wire.Envelope{Node: "nd_x", Req: "r2", App: "ap_x"}, "GET", "/api/health", 200, "ok")
|
||||
if !strings.Contains(captured.String(), "audit entry could not be written") {
|
||||
t.Fatalf("a failed audit write was not reported; log was: %s", captured.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user