diff --git a/cmd/ayd/help.txt b/cmd/ayd/help.txt index 13be6bc..6da3852 100644 --- a/cmd/ayd/help.txt +++ b/cmd/ayd/help.txt @@ -18,6 +18,7 @@ Options: Ayd won't create log file if set "-" or empty. You can use time spec %Y, %y, %m, %d, %H, %M, in the file name. (default "ayd_%Y%m%d.log") + -n, --name=NAME Instance name. This will be shown in page titles and logs. -p, --port=PORT Listen port of status page. (default 9000) -u, --user=USER[:PASS] Username and password for HTTP basic auth. -c, --ssl-cert=FILE Path to certificate file for HTTPS. Please set also -k. diff --git a/cmd/ayd/main.go b/cmd/ayd/main.go index 292c256..58cfe69 100644 --- a/cmd/ayd/main.go +++ b/cmd/ayd/main.go @@ -16,23 +16,20 @@ import ( "github.com/spf13/pflag" ) -func init() { - scheme.HTTPUserAgent = fmt.Sprintf("ayd/%s health check", meta.Version) -} - type AydCommand struct { OutStream io.Writer ErrStream io.Writer - ListenPort int - StorePath string - OneshotMode bool - AlertURLs []string - UserInfo string - CertPath string - KeyPath string - ShowVersion bool - ShowHelp bool + ListenPort int + StorePath string + InstanceName string + OneshotMode bool + AlertURLs []string + UserInfo string + CertPath string + KeyPath string + ShowVersion bool + ShowHelp bool Tasks []Task StartedAt time.Time @@ -60,6 +57,7 @@ func (cmd *AydCommand) ParseArgs(args []string) (exitCode int) { flags.IntVarP(&cmd.ListenPort, "port", "p", 9000, "HTTP listen port") flags.StringVarP(&cmd.StorePath, "log-file", "f", "ayd_%Y%m%d.log", "Path to log file") + flags.StringVarP(&cmd.InstanceName, "name", "n", "", "Instance name") flags.BoolVarP(&cmd.OneshotMode, "oneshot", "1", false, "Check status only once and exit") flags.StringArrayVarP(&cmd.AlertURLs, "alert", "a", nil, "The alert URLs") flags.StringVarP(&cmd.UserInfo, "user", "u", "", "Username and password for HTTP endpoint") @@ -133,7 +131,7 @@ func (cmd *AydCommand) Run(args []string) (exitCode int) { return 0 } - s, err := store.New(cmd.StorePath, cmd.OutStream) + s, err := store.New(cmd.InstanceName, cmd.StorePath, cmd.OutStream) if err != nil { fmt.Fprintf(cmd.ErrStream, "error: failed to open log file: %s\n", err) return 1 diff --git a/cmd/ayd/main_test.go b/cmd/ayd/main_test.go index 065bebd..a9b270e 100644 --- a/cmd/ayd/main_test.go +++ b/cmd/ayd/main_test.go @@ -117,6 +117,24 @@ func TestAydCommand_ParseArgs(t *testing.T) { Pattern: "invalid argument:\n ::invalid URL: Not valid as schedule or target URL.\n\nPlease see `ayd -h` for more information\\.\n", ExitCode: 2, }, + { + Args: []string{"ayd", "dummy:"}, + ExitCode: 0, + Extra: func(t *testing.T, cmd main.AydCommand) { + if cmd.InstanceName != "" { + t.Errorf("expected InstanceName is empty in default but got %q", cmd.InstanceName) + } + }, + }, + { + Args: []string{"ayd", "-n", "Test Instance", "dummy:"}, + ExitCode: 0, + Extra: func(t *testing.T, cmd main.AydCommand) { + if cmd.InstanceName != "Test Instance" { + t.Errorf("expected InstanceName is %q but got %q", "Test Instance", cmd.InstanceName) + } + }, + }, } for _, tt := range tests { diff --git a/cmd/ayd/server.go b/cmd/ayd/server.go index fc8e545..a877118 100644 --- a/cmd/ayd/server.go +++ b/cmd/ayd/server.go @@ -33,31 +33,39 @@ func (cmd *AydCommand) reportStartServer(s *store.Store, protocol, listen string cmd.StartedAt = time.Now() u := &api.URL{Scheme: "ayd", Opaque: "server"} + extra := map[string]interface{}{ + "url": fmt.Sprintf("%s://%s", protocol, listen), + "targets": tasks, + "version": fmt.Sprintf("%s (%s)", meta.Version, meta.Commit), + } + if s.Name() != "" { + extra["instance_name"] = s.Name() + } s.Report(u, api.Record{ Time: cmd.StartedAt, Status: api.StatusHealthy, Target: u, Message: "start Ayd server", - Extra: map[string]interface{}{ - "url": fmt.Sprintf("%s://%s", protocol, listen), - "targets": tasks, - "version": fmt.Sprintf("%s (%s)", meta.Version, meta.Commit), - }, + Extra: extra, }) } func (cmd *AydCommand) reportStopServer(s *store.Store, protocol, listen string) { u := &api.URL{Scheme: "ayd", Opaque: "server"} + extra := map[string]interface{}{ + "url": fmt.Sprintf("%s://%s", protocol, listen), + "version": fmt.Sprintf("%s (%s)", meta.Version, meta.Commit), + "since": cmd.StartedAt.Format(time.RFC3339), + } + if s.Name() != "" { + extra["instance_name"] = s.Name() + } s.Report(u, api.Record{ Time: time.Now(), Status: api.StatusHealthy, Target: u, Message: "stop Ayd server", - Extra: map[string]interface{}{ - "url": fmt.Sprintf("%s://%s", protocol, listen), - "version": fmt.Sprintf("%s (%s)", meta.Version, meta.Commit), - "since": cmd.StartedAt.Format(time.RFC3339), - }, + Extra: extra, }) } diff --git a/cmd/ayd/server_test.go b/cmd/ayd/server_test.go index 0f5fe1a..7470d48 100644 --- a/cmd/ayd/server_test.go +++ b/cmd/ayd/server_test.go @@ -61,7 +61,7 @@ func TestRunServer_tls(t *testing.T) { log, stdout := io.Pipe() defer log.Close() defer stdout.Close() - s := testutil.NewStoreWithConsole(t, stdout) + s := testutil.NewStore(t, testutil.WithConsole(stdout)) defer s.Close() cert := testutil.NewCertificate(t) @@ -129,7 +129,7 @@ func TestRunServer_tls_error(t *testing.T) { cmd.CertPath = tt.Cert cmd.KeyPath = tt.Key - s := testutil.NewStoreWithConsole(t, output) + s := testutil.NewStore(t, testutil.WithConsole(output)) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() diff --git a/go.mod b/go.mod index 1d902f0..d7a7549 100644 --- a/go.mod +++ b/go.mod @@ -20,9 +20,9 @@ require ( github.com/spf13/pflag v1.0.10 github.com/xuri/excelize/v2 v2.10.0 goftp.io/server v0.4.1 - golang.org/x/crypto v0.43.0 + golang.org/x/crypto v0.44.0 golang.org/x/sys v0.38.0 - golang.org/x/text v0.30.0 + golang.org/x/text v0.31.0 ) require ( diff --git a/go.sum b/go.sum index d9ec155..d89da73 100644 --- a/go.sum +++ b/go.sum @@ -77,8 +77,8 @@ goftp.io/server v0.4.1/go.mod h1:hFZeR656ErRt3ojMKt7H10vQ5nuWV1e0YeUTeorlR6k= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -96,12 +96,12 @@ golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/internal/endpoint/benchmark_test.go b/internal/endpoint/benchmark_test.go index e328f9b..82f94a7 100644 --- a/internal/endpoint/benchmark_test.go +++ b/internal/endpoint/benchmark_test.go @@ -37,7 +37,7 @@ func Benchmark_endpoints(b *testing.B) { for _, tt := range benchmarks { b.Run(tt.Path, func(b *testing.B) { - s := testutil.NewStoreWithLog(b) + s := testutil.NewStore(b, testutil.WithLog()) defer s.Close() h := tt.Endpoint(s) diff --git a/internal/endpoint/healthz_test.go b/internal/endpoint/healthz_test.go index c831a63..dd3c858 100644 --- a/internal/endpoint/healthz_test.go +++ b/internal/endpoint/healthz_test.go @@ -40,6 +40,10 @@ type DummyErrorsGetter struct { messages []string } +func (d DummyErrorsGetter) Name() string { + return "dummy" +} + func (d DummyErrorsGetter) Path() string { return "" } diff --git a/internal/endpoint/log.go b/internal/endpoint/log.go index 926e296..ed20286 100644 --- a/internal/endpoint/log.go +++ b/internal/endpoint/log.go @@ -367,6 +367,7 @@ func LogHTMLEndpoint(s Store) http.HandlerFunc { w.Header().Set("Content-Type", "text/html; charset=UTF-8") handleError(s, "log.html", tmpl.Execute(newFlushWriter(w), logData{ + InstanceName: s.Name(), Query: query, RawQuery: rawQuery.Encode(), Records: rs, @@ -382,6 +383,7 @@ func LogHTMLEndpoint(s Store) http.HandlerFunc { } type logData struct { + InstanceName string Since time.Time Until time.Time Query string diff --git a/internal/endpoint/log_fuzz_test.go b/internal/endpoint/log_fuzz_test.go index 6b53e17..4ff639e 100644 --- a/internal/endpoint/log_fuzz_test.go +++ b/internal/endpoint/log_fuzz_test.go @@ -13,7 +13,7 @@ import ( ) func FuzzLogJsonEndpoint(f *testing.F) { - s := testutil.NewStoreWithLog(f) + s := testutil.NewStore(f, testutil.WithLog()) defer s.Close() handler := endpoint.LogJsonEndpoint(s) diff --git a/internal/endpoint/log_test.go b/internal/endpoint/log_test.go index 8cee439..6520022 100644 --- a/internal/endpoint/log_test.go +++ b/internal/endpoint/log_test.go @@ -82,7 +82,7 @@ func TestLogScanner(t *testing.T) { { "LogGenerator", func(since, until time.Time) api.LogScanner { - s, err := store.New("", io.Discard) + s, err := store.New("", "", io.Discard) if err != nil { t.Fatalf("failed to create store: %s", err) } @@ -157,7 +157,7 @@ func TestLogScanner(t *testing.T) { } func TestPagingScanner(t *testing.T) { - s := testutil.NewStoreWithLog(t) + s := testutil.NewStore(t, testutil.WithLog()) messages := []string{ "hello world", @@ -218,7 +218,7 @@ func TestPagingScanner(t *testing.T) { } func TestContextScanner_scanAll(t *testing.T) { - s := testutil.NewStoreWithLog(t) + s := testutil.NewStore(t, testutil.WithLog()) r, err := s.OpenLog(time.Unix(0, 0), time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)) if err != nil { @@ -243,7 +243,7 @@ func TestContextScanner_scanAll(t *testing.T) { } func TestContextScanner_cancel(t *testing.T) { - s := testutil.NewStoreWithLog(t) + s := testutil.NewStore(t, testutil.WithLog()) r, err := s.OpenLog(time.Unix(0, 0), time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)) if err != nil { diff --git a/internal/endpoint/mcp.go b/internal/endpoint/mcp.go index d9f7bb0..37b392d 100644 --- a/internal/endpoint/mcp.go +++ b/internal/endpoint/mcp.go @@ -109,7 +109,7 @@ func jqParseURL(x any, _ []any) any { if u.Opaque != "" && u.Host == "" { switch u.Scheme { - case "ping": + case "ping", "ping4", "ping6": u.Host = u.Opaque u.Opaque = "" case "dns", "dns4", "dns6", "file", "exec", "mailto", "source": @@ -316,10 +316,22 @@ func FetchLogsByJq(ctx context.Context, s Store, input MCPLogsInput) (output MCP } func MCPHandler(s Store) http.HandlerFunc { - server := mcp.NewServer(&mcp.Implementation{ - Name: "Ayd", + impl := &mcp.Implementation{ + Name: "ayd", Version: meta.Version, - }, nil) + Title: "Ayd", + } + + opts := &mcp.ServerOptions{ + Instructions: "Ayd is a simple alive monitoring tool. The logs and status can be large, so it is recommended to extract necessary information using jq queries instead of fetching all data at once.", + } + + if s.Name() != "" { + impl.Title = fmt.Sprintf("Ayd (%s)", s.Name()) + opts.Instructions = fmt.Sprintf(`%s This Ayd instance's name is %q.`, opts.Instructions, s.Name()) + } + + server := mcp.NewServer(impl, opts) mcp.AddTool(server, &mcp.Tool{ Name: "list_targets", diff --git a/internal/endpoint/mcp_test.go b/internal/endpoint/mcp_test.go index fa30b0b..08ef632 100644 --- a/internal/endpoint/mcp_test.go +++ b/internal/endpoint/mcp_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -50,7 +51,7 @@ func TestJQQuery(t *testing.T) { "hostname": "example.com", "port": "", "path": "/path", - "queries": map[string][]any{"query": []any{"value"}}, + "queries": map[string][]any{"query": {"value"}}, "fragment": "fragment", "opaque": "", }, @@ -624,3 +625,63 @@ func TestMCPHandler_QueryLogs(t *testing.T) { RunMCPTest(t, "query_logs", tests) } + +func TestMCP_connection(t *testing.T) { + tests := []struct { + Name string + WithInstanceName bool + }{ + {"without_instance_name", false}, + {"with_instance_name", true}, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + var opts []testutil.StoreOption + if tt.WithInstanceName { + opts = append(opts, testutil.WithInstanceName("test-instance")) + } + + srv := testutil.StartTestServer(t, opts...) + t.Cleanup(func() { + srv.Close() + }) + + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "none", + }, nil) + sess, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{ + Endpoint: srv.URL + "/mcp", + }, nil) + if err != nil { + t.Fatalf("failed to connect to MCP server: %v", err) + } + defer sess.Close() + + initResult := sess.InitializeResult() + if initResult.ServerInfo.Name != "ayd" { + t.Errorf("unexpected server name: %q", initResult.ServerInfo.Name) + } + if tt.WithInstanceName { + if initResult.ServerInfo.Title != "Ayd (test-instance)" { + t.Errorf("unexpected server title: %q", initResult.ServerInfo.Title) + } + if !strings.Contains(initResult.Instructions, "test-instance") { + t.Errorf("instructions does not contain instance name: %q", initResult.Instructions) + } + } else { + if initResult.ServerInfo.Title != "Ayd" { + t.Errorf("unexpected server title: %q", initResult.ServerInfo.Title) + } + if strings.Contains(initResult.Instructions, "instance") { + t.Errorf("instructions contains instance name: %q", initResult.Instructions) + } + } + + if err := sess.Ping(t.Context(), nil); err != nil { + t.Fatalf("failed to ping MCP server: %v", err) + } + }) + } +} diff --git a/internal/endpoint/store.go b/internal/endpoint/store.go index f6ce15f..92a539c 100644 --- a/internal/endpoint/store.go +++ b/internal/endpoint/store.go @@ -7,6 +7,9 @@ import ( ) type Store interface { + // Name returns the Ayd instance name. + Name() string + // Targets returns target URLs include inactive target. Targets() []string diff --git a/internal/endpoint/templates/base.html b/internal/endpoint/templates/base.html index d0ec312..5e6ccb8 100644 --- a/internal/endpoint/templates/base.html +++ b/internal/endpoint/templates/base.html @@ -30,7 +30,7 @@