diff --git a/src/go/cmd/status-echo-server/BUILD.bazel b/src/go/cmd/status-echo-server/BUILD.bazel new file mode 100644 index 00000000..93f1e19d --- /dev/null +++ b/src/go/cmd/status-echo-server/BUILD.bazel @@ -0,0 +1,39 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test") +load("@rules_oci//oci:defs.bzl", "oci_image") +load("@rules_pkg//pkg:tar.bzl", "pkg_tar") + +package(default_visibility = ["//visibility:public"]) + +go_library( + name = "go_default_library", + srcs = ["main.go"], + importpath = "github.com/googlecloudrobotics/core/src/go/cmd/status-echo-server", + visibility = ["//visibility:private"], + deps = [ + "@com_github_googlecloudrobotics_ilog//:go_default_library", + ], +) + +go_binary( + name = "status-echo-server-app", + embed = [":go_default_library"], +) + +pkg_tar( + name = "status-echo-server-image-layer", + srcs = [":status-echo-server-app"], + extension = "tar.gz", +) + +oci_image( + name = "status-echo-server-image", + base = "@distroless_base", + entrypoint = ["/status-echo-server-app"], + tars = [":status-echo-server-image-layer"], +) + +go_test( + name = "go_default_test", + srcs = ["main_test.go"], + embed = [":go_default_library"], +) diff --git a/src/go/cmd/status-echo-server/main.go b/src/go/cmd/status-echo-server/main.go new file mode 100644 index 00000000..f65437b7 --- /dev/null +++ b/src/go/cmd/status-echo-server/main.go @@ -0,0 +1,76 @@ +// Copyright 2026 The Cloud Robotics Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main runs a multiplexing HTTP relay server. +// +// It exists to make HTTP endpoints on robots accessible without a public +// endpoint. It binds to a public endpoint accessible by both user-client and +// backend and works together with a relay-client that's colocated with the +// backend. +// +// For more details, see README.md. + +package main + +import ( + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "time" + + "github.com/googlecloudrobotics/ilog" +) + +var ( + port = flag.Int("port", 80, "Port number to listen on") + httpStatus = flag.Int("http_status", 200, "HTTP Status to echo") + logLevel = flag.Int("log_level", int(slog.LevelInfo), "the log message level required") +) + +func echoHandler(w http.ResponseWriter, req *http.Request) { + slog.Debug("echo", slog.String("path", req.URL.Path)) + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(*httpStatus) + w.Write([]byte(http.StatusText(*httpStatus))) +} + +func main() { + flag.Parse() + + // Setup logger + logHandler := ilog.NewLogHandler(slog.Level(*logLevel), os.Stderr) + slog.SetDefault(slog.New(logHandler)) + + + mux := http.NewServeMux() + mux.HandleFunc("/", echoHandler) + + addr := fmt.Sprintf(":%d", *port) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 1 * time.Second, + WriteTimeout: 2 * time.Second, + IdleTimeout: 10 * time.Second, + } + + slog.Info("Status echo server running", slog.String("address", addr)) + if err := srv.ListenAndServe(); err != nil { + slog.Error("Server failed to start", slog.Any("error", err)) + os.Exit(1) + } +} diff --git a/src/go/cmd/status-echo-server/main_test.go b/src/go/cmd/status-echo-server/main_test.go new file mode 100644 index 00000000..4f8822c9 --- /dev/null +++ b/src/go/cmd/status-echo-server/main_test.go @@ -0,0 +1,79 @@ +// Copyright 2026 The Cloud Robotics Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestEchoHandler(t *testing.T) { + tests := []struct { + desc string + path string + httpStatus int + wantStatus int + wantBody string + }{ + { + desc: "OK status on root path", + path: "/", + httpStatus: http.StatusOK, + wantStatus: http.StatusOK, + wantBody: "OK", + }, + { + desc: "Internal Server Error on nested path", + path: "/foo/bar?key=value", + httpStatus: http.StatusInternalServerError, + wantStatus: http.StatusInternalServerError, + wantBody: "Internal Server Error", + }, + { + desc: "OK status on deep path", + path: "/a/b/c/d/e", + httpStatus: http.StatusOK, + wantStatus: http.StatusOK, + wantBody: "OK", + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + *httpStatus = tc.httpStatus + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + + echoHandler(w, req) + + resp := w.Result() + if resp.StatusCode != tc.wantStatus { + t.Errorf("got %d, want %d", resp.StatusCode, tc.wantStatus) + } + + contentType := resp.Header.Get("Content-Type") + if contentType != "text/plain" { + t.Errorf("got %q, want %q", contentType, "text/plain") + } + + body := w.Body.String() + if body != tc.wantBody { + t.Errorf("got %q, want %q", body, tc.wantBody) + } + }) + } +}