20184874b2
Backlog item #3 (20-07-2026-BACKLOG.md). A morning routine is a checklist for a daily window: several items, each evidenced by a fact key, completed in any order, checked once near the end of the window. Modelling it as four independent reminder timers would stack into exactly the kind of noise Maven is supposed not to produce, so the engine nags at most once per day per routine and only for what is actually still missing. internal/morning follows the established pure-engine pattern (loop, routine, pattern): no store, no clock of its own. Evaluate answers "what's still missing" at any point; Due decides whether to nag. The impurity — reading facts under the store lock, holding the last-nudge map across ticks — stays in the tick driver, which calls Due each tick exactly as it does for loop.Rule and routine.Routine. Completion evidence is a fact key's latest non-voided value timestamped inside today's window, so manual ("выпил воды", voice-tapped) and inferred (another daemon writing the same key) are indistinguishable and both count. Weekdays scopes which days a routine applies to, so weekday/weekend variants are two routine rows rather than a special case in the engine. Exposed read-only: a MorningStatus RPC over ipc, and a /morning page in mavweb built on the same server-rendered shape as /trace — no live-update loop, since checklist state moves on the scale of minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
472 lines
16 KiB
Go
472 lines
16 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
func TestMaxLayer_SurfaceCapsAuthority(t *testing.T) {
|
|
// The invariant: surface caps maximum authority. A surface structurally
|
|
// unable to carry passkey-user-verification caps below L3 — voice never
|
|
// reaches step-up, telegram never reaches step-up, pc_client + authed_page
|
|
// + the daemon's own process DO.
|
|
cases := []struct {
|
|
surface Surface
|
|
max Layer
|
|
}{
|
|
{SurfaceVoice, Layer0},
|
|
{SurfaceTelegram, Layer2},
|
|
{SurfacePCClient, Layer3},
|
|
{SurfaceAuthedPage, Layer3},
|
|
{SurfaceCoreProcess, Layer3},
|
|
{SurfaceUnknown, -1},
|
|
}
|
|
for _, c := range cases {
|
|
got := MaxLayer(c.surface)
|
|
if got != c.max {
|
|
t.Errorf("MaxLayer(%s) = %d; want %d (surface caps authority)", c.surface, got, c.max)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSourceAllowed(t *testing.T) {
|
|
cases := []struct {
|
|
scope []string
|
|
src string
|
|
want bool
|
|
}{
|
|
{[]string{"*"}, "anything", true},
|
|
{[]string{"poll:healthcheck"}, "poll:healthcheck", true},
|
|
{[]string{"poll:healthcheck"}, "poll:uptime", false}, // compromised poller can't forge a trigger
|
|
{[]string{"poll:healthcheck", "tap:water"}, "tap:water", true},
|
|
{[]string{"poll:healthcheck", "tap:water"}, "ambient", false},
|
|
{[]string{}, "anything", false}, // fail closed
|
|
{nil, "anything", false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := SourceAllowed(c.scope, c.src); got != c.want {
|
|
t.Errorf("SourceAllowed(%v, %q) = %v; want %v", c.scope, c.src, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRequirement_Table(t *testing.T) {
|
|
// WriteFact is AuthWrite (carries source-scope); EnableTool is AuthStepUp
|
|
// (registration-enable moves the boundary); all others are AuthRead.
|
|
if got := Requirement(ipc.MethodWriteFact); got != AuthWrite {
|
|
t.Errorf("WriteFact authority = %v; want AuthWrite", got)
|
|
}
|
|
if got := Requirement(ipc.MethodEnableTool); got != AuthStepUp {
|
|
t.Errorf("EnableTool authority = %v; want AuthStepUp", got)
|
|
}
|
|
reads := []ipc.Method{
|
|
ipc.MethodLatestFact, ipc.MethodLatestFactBySource, ipc.MethodSince,
|
|
ipc.MethodPresence, ipc.MethodRecentOutcomes,
|
|
ipc.MethodCreateReminder, ipc.MethodMarkReminder,
|
|
ipc.MethodRecordNudge, ipc.MethodResolveNudge,
|
|
ipc.MethodChat,
|
|
}
|
|
for _, m := range reads {
|
|
if got := Requirement(m); got != AuthRead {
|
|
t.Errorf("%s authority = %v; want AuthRead", m, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGate_EnableTool_StepUp(t *testing.T) {
|
|
// EnableTool is AuthStepUp. At the floor (FloorEnrollment L3 + FloorSession
|
|
// asserting L3), the local caller may enable — the authed mavweb /tools page
|
|
// hits this path. With a nil Session (no step-up asserted), it's refused —
|
|
// step-up can't be granted from what wasn't demonstrated.
|
|
ctx := context.Background()
|
|
withSession := &Gate{Enrollment: NewFloorEnrollment(), Session: FloorSession{}}
|
|
if err := withSession.Check(ctx, ipc.MethodEnableTool, nil); err != nil {
|
|
t.Errorf("EnableTool with FloorSession = %v; want nil (floor permits)", err)
|
|
}
|
|
noSession := &Gate{Enrollment: NewFloorEnrollment()}
|
|
if err := noSession.Check(ctx, ipc.MethodEnableTool, nil); !errors.Is(err, ipc.ErrForbidden) {
|
|
t.Errorf("EnableTool with nil Session = %v; want ErrForbidden (fail closed)", err)
|
|
}
|
|
}
|
|
|
|
func TestCan_Unenrolled_FailClosed(t *testing.T) {
|
|
// No Module / unknown surface ⇒ refused, NOT "0-level authed". This is
|
|
// the surface-caps property applied before the layer caps.
|
|
if err := Can(ipc.MethodPresence, Scope{}, nil); !errors.Is(err, ErrUnenrolled) {
|
|
t.Errorf("Can with empty Scope = %v; want ErrUnenrolled", err)
|
|
}
|
|
if err := Can(ipc.MethodPresence, Scope{Module: "x", Surface: SurfaceUnknown}, nil); !errors.Is(err, ErrUnenrolled) {
|
|
t.Errorf("Can with unknown surface = %v; want ErrUnenrolled", err)
|
|
}
|
|
}
|
|
|
|
func TestCan_WriteFact_SourceScope(t *testing.T) {
|
|
// The spec's compromised-poller case: a poller enrolled to write
|
|
// poll:healthcheck can't forge poll:uptime (or anything else).
|
|
poller := Scope{
|
|
Surface: SurfaceCoreProcess,
|
|
Module: "poller:healthcheck",
|
|
SourceScope: []string{"poll:healthcheck"},
|
|
}
|
|
core := Scope{
|
|
Surface: SurfaceCoreProcess,
|
|
Module: "core",
|
|
SourceScope: []string{"*"},
|
|
}
|
|
|
|
// In-scope write succeeds.
|
|
if err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:healthcheck")); err != nil {
|
|
t.Errorf("poller writing poll:healthcheck = %v; want nil", err)
|
|
}
|
|
// Out-of-scope write is forbidden — auth.ErrForbidden in the chain.
|
|
err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:uptime"))
|
|
if err == nil || !errors.Is(err, ErrForbidden) {
|
|
t.Errorf("poller writing poll:uptime = %v; want ErrForbidden in chain", err)
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "poll:uptime") {
|
|
t.Errorf("forbidden err should name the offending source: got %q", err)
|
|
}
|
|
|
|
// Core (wildcard) writes anything.
|
|
if err := Can(ipc.MethodWriteFact, core, mustWriteFactParams("poll:uptime")); err != nil {
|
|
t.Errorf("core writing poll:uptime = %v; want nil (wildcard scope)", err)
|
|
}
|
|
|
|
// Empty SourceScope ⇒ fail closed even for an enrolled module.
|
|
empty := Scope{Surface: SurfaceCoreProcess, Module: "x", SourceScope: nil}
|
|
if err := Can(ipc.MethodWriteFact, empty, mustWriteFactParams("poll:healthcheck")); !errors.Is(err, ErrForbidden) {
|
|
t.Errorf("empty SourceScope WriteFact = %v; want ErrForbidden", err)
|
|
}
|
|
}
|
|
|
|
func TestCan_Reads_AnyEnrolledModule(t *testing.T) {
|
|
// Reads permit any enrolled module (the enrollment already gated caller
|
|
// identity). Reads on SurfaceVoice are OK too — enrollment may have
|
|
// enrolled a voice module for read-only purposes (e.g. ambient parse).
|
|
for _, surf := range []Surface{SurfaceVoice, SurfaceTelegram, SurfacePCClient, SurfaceCoreProcess} {
|
|
scope := Scope{Surface: surf, Module: "x", SourceScope: []string{"*"}}
|
|
if err := Can(ipc.MethodPresence, scope, nil); err != nil {
|
|
t.Errorf("read on %s = %v; want nil (any enrolled module may read)", surf, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Gate ---
|
|
|
|
func TestGate_FloorEnrollment_PreservesPreAuth(t *testing.T) {
|
|
// The floor must be a no-op vs pre-auth: same-uid trusted, full source
|
|
// scope, every read and in-scope write passes. Tied end-to-end through
|
|
// Gate.Check on the ipc.Method enumeration, so AuthRead / AuthWrite both
|
|
// flow through the composition.
|
|
g := &Gate{Enrollment: NewFloorEnrollment()}
|
|
ctx := context.Background()
|
|
for _, m := range []ipc.Method{
|
|
ipc.MethodPresence, ipc.MethodSince, ipc.MethodRecentOutcomes,
|
|
ipc.MethodCreateReminder, ipc.MethodRecordNudge,
|
|
ipc.MethodChat,
|
|
} {
|
|
if err := g.Check(ctx, m, nil); err != nil {
|
|
t.Errorf("floor gate Check(%s) = %v; want nil (pre-auth preserved)", m, err)
|
|
}
|
|
}
|
|
if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime")); err != nil {
|
|
t.Errorf("floor gate Check(WriteFact, *) = %v; want nil (full source scope)", err)
|
|
}
|
|
}
|
|
|
|
func TestGate_StaticEnrollment_SourceScopeEnforced(t *testing.T) {
|
|
// A poller enrolled to write only poll:healthcheck is denied a write to
|
|
// poll:uptime. Gate.Authoritative on real source-scope. Wire code should
|
|
// be forbidden once we wire through the ipc layer (next test exercises
|
|
// that path through a real socket).
|
|
pollerUid := int32(70001)
|
|
enrollment := &StaticEnrollment{
|
|
ByUid: map[int32]Scope{
|
|
pollerUid: {
|
|
Surface: SurfaceCoreProcess,
|
|
Module: "poll:healthcheck",
|
|
SourceScope: []string{"poll:healthcheck"},
|
|
},
|
|
},
|
|
Default: &Scope{ // any other uid gets full trust — for tests, doesn't matter
|
|
Surface: SurfaceCoreProcess,
|
|
Module: "core",
|
|
SourceScope: []string{"*"},
|
|
},
|
|
}
|
|
g := &Gate{Enrollment: enrollment}
|
|
|
|
ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1234})
|
|
if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil {
|
|
t.Errorf("poller in-scope write = %v; want nil", err)
|
|
}
|
|
err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime"))
|
|
if err == nil {
|
|
t.Fatalf("poller out-of-scope write returned nil; want forbidden")
|
|
}
|
|
if !errors.Is(err, ipc.ErrForbidden) {
|
|
t.Errorf("poller out-of-scope err chain = %v; want ipc.ErrForbidden in chain (wire code wraps ipc.ErrForbidden)", err)
|
|
}
|
|
if !errors.Is(err, ErrForbidden) {
|
|
t.Errorf("poller out-of-scope err chain = %v; want auth.ErrForbidden in chain (auth tests can satisfy Is)", err)
|
|
}
|
|
}
|
|
|
|
func TestGate_Unenrolled_FailsClosed(t *testing.T) {
|
|
enrollment := &StaticEnrollment{} // no Default ⇒ ErrUnenrolled for any caller
|
|
g := &Gate{Enrollment: enrollment}
|
|
ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: 4242, Pid: 99})
|
|
err := g.Check(ctx, ipc.MethodPresence, nil)
|
|
if err == nil {
|
|
t.Fatalf("un-enrolled caller returned nil; want forbidden")
|
|
}
|
|
if !errors.Is(err, ipc.ErrForbidden) {
|
|
t.Errorf("unenrolled err = %v; want ipc.ErrForbidden in chain (mapped at wire edge)", err)
|
|
}
|
|
}
|
|
|
|
// --- end-to-end through ipc.Server ---
|
|
|
|
// TestGate_IpcServer_WiresCheck end-to-end. Server.Check = g.Check; an
|
|
// out-of-scope WriteFact sent through a real socket rehydrates as
|
|
// ipc.ErrForbidden on the wire. The seam — no CoreAPI change, no module
|
|
// change — is the whole point: the daemon sets Server.Check at construction.
|
|
func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) {
|
|
pollerUid := int32(70001)
|
|
enrollment := &StaticEnrollment{
|
|
ByUid: map[int32]Scope{
|
|
pollerUid: {
|
|
Surface: SurfaceCoreProcess,
|
|
Module: "poll:healthcheck",
|
|
SourceScope: []string{"poll:healthcheck"},
|
|
},
|
|
},
|
|
Default: &Scope{ // any other uid ⇒ un-enrolled → forbidden
|
|
Surface: SurfaceUnknown,
|
|
Module: "",
|
|
},
|
|
}
|
|
gate := &Gate{Enrollment: enrollment}
|
|
|
|
dir := t.TempDir()
|
|
sock := filepath.Join(dir, "maven.sock")
|
|
// A fake CoreAPI that records writes; the auth verdict should fire before
|
|
// it ever gets called.
|
|
fake := &recordingAPI{}
|
|
srv, err := ipc.Listen(sock, fake)
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
srv.Check = gate.Check
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_ = srv.Serve()
|
|
close(done)
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = srv.Close()
|
|
<-done
|
|
})
|
|
|
|
// Direct in-process check: we can caller-stamp our test ctx with any
|
|
// uid/pid we want. The socket path produces our real uid (via
|
|
// SO_PEERCRED), but the auth layer's verdict depends only on the
|
|
// caller-shape the wire delivered, not the wire transport — so the
|
|
// in-process check uses the same Gate.Check the socketed dispatch calls.
|
|
ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1})
|
|
if err := gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil {
|
|
t.Errorf("in-scope write through gate = %v; want nil", err)
|
|
}
|
|
err = gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime"))
|
|
if !errors.Is(err, ipc.ErrForbidden) {
|
|
t.Errorf("out-of-scope write through gate = %v; want ipc.ErrForbidden", err)
|
|
}
|
|
|
|
// Smoke the actual wire path: our real uid is un-enrolled per the
|
|
// StaticEnrollment (only 70001 is enrolled), so the very next call we
|
|
// make over the socket is forbidden at the wire — rehydrating on the
|
|
// client side as ipc.ErrForbidden. This is the full chain:
|
|
// dispatch → Check (auth) → codeOf → wire → hydrate.
|
|
cli, err := ipc.Dial(sock)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = cli.Close() })
|
|
_, err = cli.WriteFact(context.Background(), ipc.WriteFactReq{
|
|
Kind: "env",
|
|
Key: "service_down",
|
|
Value: "down",
|
|
Source: "poll:healthcheck",
|
|
Confidence: 1.0,
|
|
})
|
|
if !errors.Is(err, ipc.ErrForbidden) {
|
|
t.Errorf("wire: write from real uid (unenrolled per StaticEnrollment) = %v; want ipc.ErrForbidden", err)
|
|
}
|
|
if fake.writes != 0 {
|
|
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
|
|
}
|
|
_, err = cli.Chat(context.Background(), "привет")
|
|
if !errors.Is(err, ipc.ErrForbidden) {
|
|
t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err)
|
|
}
|
|
if fake.chats != 0 {
|
|
t.Errorf("auth refused chat but CoreAPI.Chat was called %d time(s)", fake.chats)
|
|
}
|
|
}
|
|
|
|
func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
|
|
gate := &Gate{Enrollment: NewFloorEnrollment()}
|
|
fake := &recordingAPI{}
|
|
sock := filepath.Join(t.TempDir(), "maven.sock")
|
|
srv, err := ipc.Listen(sock, fake)
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
srv.Check = gate.Check
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_ = srv.Serve()
|
|
close(done)
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = srv.Close()
|
|
<-done
|
|
})
|
|
|
|
cli, err := ipc.Dial(sock)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = cli.Close() })
|
|
reply, err := cli.Chat(context.Background(), "привет")
|
|
if err != nil {
|
|
t.Fatalf("Chat: %v", err)
|
|
}
|
|
if reply != "echo: привет" {
|
|
t.Fatalf("Chat reply = %q; want %q", reply, "echo: привет")
|
|
}
|
|
if fake.chats != 1 {
|
|
t.Fatalf("CoreAPI.Chat calls = %d; want 1", fake.chats)
|
|
}
|
|
}
|
|
|
|
// recordingAPI — a no-op CoreAPI that counts WriteFact invocations; the auth
|
|
// check must reject before reaching it, otherwise the refusal leaks into the
|
|
// fake's counts and we fail.
|
|
type recordingAPI struct {
|
|
writes int
|
|
chats int
|
|
}
|
|
|
|
func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64, error) {
|
|
r.writes++
|
|
return int64(r.writes), nil
|
|
}
|
|
func (r *recordingAPI) LatestFact(_ context.Context, _ string) (ipc.Fact, error) {
|
|
return ipc.Fact{}, ipc.ErrNoFact
|
|
}
|
|
func (r *recordingAPI) LatestFactBySource(_ context.Context, _, _ string) (ipc.Fact, error) {
|
|
return ipc.Fact{}, ipc.ErrNoFact
|
|
}
|
|
func (r *recordingAPI) Since(_ context.Context, _ string, _ time.Time) (time.Duration, error) {
|
|
return 0, ipc.ErrNoFact
|
|
}
|
|
func (r *recordingAPI) Presence(_ context.Context) (ipc.Presence, error) {
|
|
return ipc.Presence{}, nil
|
|
}
|
|
func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _, _ string) (int64, error) {
|
|
return 1, nil
|
|
}
|
|
func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil }
|
|
func (r *recordingAPI) ListReminders(_ context.Context, _ int) ([]ipc.Reminder, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) TickTrace(_ context.Context) (ipc.TickTrace, error) {
|
|
return ipc.TickTrace{}, nil
|
|
}
|
|
func (r *recordingAPI) MorningStatus(_ context.Context) ([]ipc.MorningRoutineStatus, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) RecordNudge(_ context.Context, _, _, _ string, _ time.Time) (int64, error) {
|
|
return 1, nil
|
|
}
|
|
func (r *recordingAPI) ResolveNudge(_ context.Context, _ int64, _ string, _ time.Time) error {
|
|
return nil
|
|
}
|
|
func (r *recordingAPI) RecentOutcomes(_ context.Context, _ string, _ int) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) CalendarEvents(_ context.Context, _, _ time.Time) ([]ipc.Fact, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) WriteNote(_ context.Context, _ time.Time, _ string, _ []float32, _ string) (int64, error) {
|
|
return 1, nil
|
|
}
|
|
func (r *recordingAPI) QueryNotes(_ context.Context, _ []float32, _ int) ([]ipc.Note, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) ProposeTool(_ context.Context, _, _, _ string, _ time.Time) (bool, error) {
|
|
return false, nil
|
|
}
|
|
func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ string, _ time.Time) error {
|
|
return nil
|
|
}
|
|
func (r *recordingAPI) DisableTool(_ context.Context, _ string) error {
|
|
return nil
|
|
}
|
|
func (r *recordingAPI) DeleteTool(_ context.Context, _ string) error {
|
|
return nil
|
|
}
|
|
func (r *recordingAPI) LookupTool(_ context.Context, _ string) (ipc.Tool, error) {
|
|
return ipc.Tool{}, ipc.ErrToolNotFound
|
|
}
|
|
func (r *recordingAPI) ListTools(_ context.Context, _ string) ([]ipc.Tool, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
|
return nil
|
|
}
|
|
func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) {
|
|
r.chats++
|
|
return "echo: " + text, nil
|
|
}
|
|
|
|
// mustWriteFactParams — minimal WriteFactReq JSON with only the source field,
|
|
// matching what ipc.dispatch hands to Server.Check (the raw params frame).
|
|
func mustWriteFactParams(source string) []byte {
|
|
b, err := json.Marshal(ipc.WriteFactReq{
|
|
Kind: "env",
|
|
Key: "service_down",
|
|
Value: "down",
|
|
Source: source,
|
|
Confidence: 1.0,
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|