diff --git a/build.sh b/build.sh index 0eabe9e..dcc7473 100755 --- a/build.sh +++ b/build.sh @@ -92,9 +92,11 @@ OUT_DIR="$AGENT_RES_DIR/build" SRC_RES="$AGENT_RES_DIR/resources.c" SRC_ARENA="$AGENT_RES_DIR/arena.c" +SRC_BASE="$AGENT_RES_DIR/base.c" OUT_OBJ_RES="$OUT_DIR/resources.o" OUT_OBJ_ARENA="$OUT_DIR/arena.o" +OUT_OBJ_BASE="$OUT_DIR/base.o" OUT_LIB="$OUT_DIR/libagent_resources.a" @@ -133,10 +135,13 @@ $CC $CFLAGS -c "$SRC_RES" -o "$OUT_OBJ_RES" echo "Compiling arena.c..." $CC $CFLAGS -c "$SRC_ARENA" -o "$OUT_OBJ_ARENA" +echo "Compiling base.c..." +$CC $CFLAGS -c "$SRC_BASE" -o "$OUT_OBJ_BASE" + echo "Creating static library..." # $AR rcs "$OUT_LIB" "$OUT_OBJ" -$AR rcs "$OUT_LIB" "$OUT_OBJ_RES" "$OUT_OBJ_ARENA" +$AR rcs "$OUT_LIB" "$OUT_OBJ_RES" "$OUT_OBJ_ARENA" "$OUT_OBJ_BASE" echo "Done: $OUT_LIB" diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go index 0ee0202..0bf0755 100644 --- a/cmd/agent/agent.go +++ b/cmd/agent/agent.go @@ -1,16 +1,18 @@ -// Package main boots the Paradigm Agent gRPC server. -// -// Responsibilities: -// - Parse runtime flags (IP, port, diagnostics) -// - Bind a TCP listener with automatic port fallback -// - Initialize and register all gRPC services -// - Expose health and reflection endpoints -// -// Design notes: -// - The server must be able to start even if the preferred port is occupied. -// - Reflection is enabled by default for debugging and introspection. -// - Diagnostics are intentionally decoupled from server startup to avoid -// blocking gRPC reflection and request handling. +/* +Package main boots the Paradigm Agent gRPC server. + +Responsibilities: +- Parse runtime flags (IP, port, diagnostics) +- Bind a TCP listener with automatic port fallback +- Initialize and register all gRPC services +- Expose health and reflection endpoints + +Design notes: +- The server must be able to start even if the preferred port is occupied. +- Reflection is enabled by default for debugging and introspection. +- Diagnostics are intentionally decoupled from server startup to avoid + blocking gRPC reflection and request handling. +*/ package main import ( @@ -22,9 +24,15 @@ import ( devacpb "paradigm-ehb/agent/gen/actions/v1" "paradigm-ehb/agent/gen/greet" "paradigm-ehb/agent/gen/journal/v1" - "paradigm-ehb/agent/gen/resources/v1" - services_v1 "paradigm-ehb/agent/gen/services/v1" - services_v2 "paradigm-ehb/agent/gen/services/v2" + + "log" + + servicesV1 "paradigm-ehb/agent/gen/services/v1" + servicesV2 "paradigm-ehb/agent/gen/services/v2" + servicesV3 "paradigm-ehb/agent/gen/services/v3" + + resourcesv1 "paradigm-ehb/agent/gen/resources/v1" + resourcesv2 "paradigm-ehb/agent/gen/resources/v2" "google.golang.org/grpc" "google.golang.org/grpc/health" @@ -33,54 +41,82 @@ import ( "paradigm-ehb/agent/internal/platform" - "paradigm-ehb/agent/pkg/grpc_handler" + "paradigm-ehb/agent/pkg/grpchandler" + + resourcesHandlerV1 "paradigm-ehb/agent/pkg/grpchandler/resources/v1" + resourcesHandlerV2 "paradigm-ehb/agent/pkg/grpchandler/resources/v2" + + servicesHandlerV1 "paradigm-ehb/agent/pkg/grpchandler/services/v1" + servicesHandlerV2 "paradigm-ehb/agent/pkg/grpchandler/services/v2" + servicesHandlerV3 "paradigm-ehb/agent/pkg/grpchandler/services/v3" + "syscall" "time" ) var ( - /** - diagnostics enables periodic runtime diagnostics such as - resource usage, process health, and connectivity checks. - NOTE: When enabled, diagnostics must never block the gRPC server. - **/ + /* + diagnostics enables periodic runtime diagnostics such as + resource usage, process health, and connectivity checks. - diagnostics = flag.Bool("diagnostics", true, "run runtime diagnostics") + TODO: + - Add structured configuration for diagnostics intervals. + - Allow diagnostics to be toggled or reconfigured at runtime. + - Ensure diagnostics respect context cancellation on shutdown. - /** - portFlag is the preferred TCP port to bind the gRPC server to. - If unavailable, the server will increment the port until a free - one is found. + NOTE: + When enabled, diagnostics must never block the gRPC server. */ + diagnostics = flag.Bool("diagnostics", false, "run runtime diagnostics") + /* + portFlag is the preferred TCP port to bind the gRPC server to. + + If unavailable: + - The server increments the port until a free one is found. + + TODO: + - Log the final selected port explicitly. + - Optionally expose the selected port via diagnostics or metadata. + */ portFlag = flag.Int("port", 5000, "port to listen on") - /** - ipFlag defines the IP address diagnostics may use when reporting - or exposing runtime information. + /* + ipFlag defines the IP address diagnostics may use when reporting + or exposing runtime information. + + TODO: + - Validate IP format early. + - Clarify distinction between bind address vs diagnostics address. */ ipFlag = flag.String("ip", "0.0.0.0", "ip addr") ) func main() { - /** + /* Parse command-line flags before any runtime behavior. - */ + TODO: + - Add validation for flag combinations. + - Print effective configuration at startup. + */ flag.Parse() - /** + /* Attempt to bind a TCP listener. Strategy: - - Start with the requested port - - If EADDRINUSE is encountered, increment the port and retry - - Fail hard on any other error + - Start with the requested port + - If EADDRINUSE is encountered, increment the port and retry + - Fail hard on any other error This guarantees the agent can always start, even in constrained or multi-agent environments. + TODO: + - Add an upper bound to port scanning. + - Support IPv6 or configurable network protocols. */ var lis net.Listener var err error @@ -100,83 +136,125 @@ func main() { } } - /** + /* Any error other than "address already in use" is fatal. + + TODO: + - Emit structured logs. + - Exit with non-zero status code. */ - fmt.Println("failed to listen:", err) + log.Printf("failed to listen: %v", err) return } break } - /** + /* Create the gRPC server instance. + + TODO: + - Configure server options (timeouts, interceptors, limits). + - Add graceful shutdown handling. */ server := grpc.NewServer() - /** + /* Health server is used by orchestration systems (systemd, Kubernetes, external monitors) to determine liveness and readiness. + + TODO: + - Set explicit serving statuses per service. */ healthServer := health.NewServer() grpc_health_v1.RegisterHealthServer(server, healthServer) - /** - Register all application services. + /* + Register all application services. - Each service implements a distinct responsibility: - - Greeter: connectivity / handshake testing - - HandlerService: service lifecycle and orchestration - - JournalService: event and state journaling - - ResourcesService: system resource inspection and reporting + Each service implements a distinct responsibility: + - Greeter: connectivity / handshake testing + - HandlerService: service lifecycle and orchestration + - JournalService: event and state journaling + - ResourcesService: system resource inspection and reporting + TODO: + - Centralize service registration. + - Version-gate deprecated service versions. */ - greet.RegisterGreeterServer(server, &grpc_handler.GreeterServer{}) - services_v1.RegisterHandlerServiceServer(server, &grpc_handler.HandlerService{}) - services_v2.RegisterHandlerServiceServer(server, &grpc_handler.HandlerServiceV2{}) - journal.RegisterJournalServiceServer(server, &grpc_handler.JournalService{}) - resourcespb.RegisterResourcesServiceServer(server, &grpc_handler.ResourcesService{}) - devacpb.RegisterActionServiceServer(server, &grpc_handler.DeviceActionsService{}) + greet.RegisterGreeterServer( + server, + &grpc_handler.GreeterServer{}, + ) + + servicesV1.RegisterHandlerServiceServer( + server, + &servicesHandlerV1.HandlerService{}, + ) + + servicesV2.RegisterHandlerServiceServer( + server, + &servicesHandlerV2.HandlerServiceV2{}, + ) + + servicesV3.RegisterHandlerServiceServer( + server, + &servicesHandlerV3.HandlerServicev3{}, + ) + + journal.RegisterJournalServiceServer( + server, + &grpc_handler.JournalService{}, + ) + + resourcesv1.RegisterResourcesServiceServer( + server, + &resourcesHandlerV1.ResourcesService{}, + ) + + resourcesv2.RegisterResourcesServiceServer( + server, + &resourcesHandlerV2.ResourcesServiceV2{}, + ) + + devacpb.RegisterActionServiceServer( + server, + &grpc_handler.DeviceActionsService{}, + ) /* - Diagnostics mode (disabled for now) - - Problem: - - Running diagnostics synchronously introduces an infinite loop - that blocks gRPC reflection and request handling. - - Important invariant: - - Diagnostics must run asynchronously and must never interfere - with server startup, reflection, or request processing. - - TODO(nasr): - - Move diagnostics into a separate goroutine - - Introduce a proper shutdown context - - Ensure diagnostics respect server lifecycle - */ - - /** Enable gRPC reflection unconditionally. This allows tools such as grpcurl and gRPC UI to inspect services and message schemas at runtime. + + TODO: + - Make reflection configurable for production environments. */ reflection.Register(server) - fmt.Printf("\nserver listening at %v\n", lis.Addr()) + + log.Printf("\nserver listening at %v\n", lis.Addr()) if *diagnostics { + /* + TODO: + - Tie diagnostics lifecycle to server context. + - Ensure diagnostics terminate on server shutdown. + */ go platform.RunRuntimeDiagnostics(time.Second*2, *ipFlag, *portFlag) - } - - /** + /* Start serving requests. + This call blocks until the server is stopped or encounters a fatal error. + + TODO: + - Implement graceful shutdown (signals, context). + - Flush diagnostics and logs before exit. */ if err := server.Serve(lis); err != nil { - fmt.Println("failed to serve:", err) + log.Printf("failed to serve: %v", err) } } diff --git a/compile.sh b/compile.sh index d19a4f5..456bd75 100755 --- a/compile.sh +++ b/compile.sh @@ -1,129 +1,9 @@ #!/bin/sh -# echo ------------------------------------------------------------------------- -# echo Description: build go binary with flags -# echo ------------------------------------------------------------------------- -VERSION=1.0 -prod() { - go build \ - -ldflags="-s -w -X main.version=${VERSION}" \ - -o agent \ - ./cmd/agent/agent.go -} +go clean -cache -debug() { - go build \ - -race \ - -gcflags="all=-N -l" \ - -o debug \ - ./cmd/agent/agent.go -} - -valgrind() { - echo "Building with debug symbols for valgrind..." - CGO_CFLAGS="-g -O0" \ - CGO_LDFLAGS="-g" \ - go build \ - -gcflags="all=-N -l" \ - -o debug_valgrind \ - ./cmd/agent/agent.go - - echo "Running valgrind..." - valgrind \ - --leak-check=full \ - --show-leak-kinds=all \ - --track-origins=yes \ - --verbose \ - --log-file=valgrind-out.txt \ - ./debug_valgrind - - echo "Valgrind output saved to valgrind-out.txt" -} - -gdb() { - echo "Building with debug symbols for gdb..." - CGO_CFLAGS="-g -O0" \ - CGO_LDFLAGS="-g" \ - go build \ - -gcflags="all=-N -l" \ - -o debug_gdb \ - ./cmd/agent/agent.go - - echo "Starting gdb..." - gdb ./debug_gdb -} - -core() { - echo "Building with debug symbols..." - CGO_CFLAGS="-g -O0" \ - CGO_LDFLAGS="-g" \ - go build \ - -gcflags="all=-N -l" \ - -o debug_core \ - ./cmd/agent/agent.go - - echo "Enabling core dumps..." - ulimit -c unlimited - - echo "Running program (will generate core dump on crash)..." - ./debug_core - - # Check if core dump was created - if [ -f core ]; then - echo "Core dump generated. Starting gdb..." - gdb ./debug_core core - elif [ -f core.* ]; then - CORE_FILE=$(ls -t core.* | head -n1) - echo "Core dump generated: $CORE_FILE. Starting gdb..." - gdb ./debug_core "$CORE_FILE" - else - echo "No core dump found. Program may have exited normally." - echo "Core dumps might be in: /var/lib/systemd/coredump/ or /var/crash/" - echo "Check with: coredumpctl list" - fi -} - -sanitize() { - echo "Building with address sanitizer..." - CGO_CFLAGS="-fsanitize=address -g -O0" \ - CGO_LDFLAGS="-fsanitize=address" \ - go build \ - -gcflags="all=-N -l" \ - -o debug_asan \ - ./cmd/agent/agent.go - - echo "Running with address sanitizer..." - ASAN_OPTIONS=detect_leaks=1:halt_on_error=0 ./debug_asan -} - -case "$1" in - prod) - prod - ;; - debug) - debug - ;; - valgrind) - valgrind - ;; - gdb) - gdb - ;; - core) - core - ;; - sanitize|asan) - sanitize - ;; - *) - echo "Usage: $0 [prod|debug|valgrind|gdb|core|sanitize]" - echo "" - echo " prod - Production build (stripped, optimized)" - echo " debug - Debug build with race detector" - echo " valgrind - Build and run with valgrind memory checker" - echo " gdb - Build and start gdb debugger" - echo " core - Build, enable core dumps, run and analyze crash" - echo " sanitize - Build and run with address sanitizer (ASAN)" - exit 1 - ;; -esac +if CGO_ENABLED=1 go build -race -gcflags="all=-N -l" -o debug ./cmd/agent/agent.go; then + exec ./debug +else + exit 1 +fi diff --git a/go.mod b/go.mod index 8fb194a..34322c6 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,26 @@ require ( google.golang.org/protobuf v1.36.10 ) +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect +) + require ( github.com/godbus/dbus v4.1.0+incompatible golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 // indirect diff --git a/go.sum b/go.sum index 2391d25..c15576e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,21 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -12,6 +28,25 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= @@ -26,6 +61,8 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= diff --git a/internal/dbusservices/dbus/call.go b/internal/dbusservices/dbus/call.go index 9f9ac0b..5b0391a 100644 --- a/internal/dbusservices/dbus/call.go +++ b/internal/dbusservices/dbus/call.go @@ -1,6 +1,7 @@ package dbushandler import ( + "fmt" "github.com/godbus/dbus" ) @@ -9,7 +10,35 @@ import ( * used to handle services * @return BusObjcet */ -func CreateSystemdObject(conn *dbus.Conn) dbus.BusObject { +func CreateSystemdObject(conn *dbus.Conn) (dbus.BusObject, error) { + + obj := conn.Object("org.freedesktop.systemd1", "/org/freedesktop/systemd1") + if obj == nil { + + return nil, fmt.Errorf("failed to create a systemd object") + } + + return obj, nil +} + +/** + +* CreateLoginObject +* used to handle services +* @return BusObject + +*/ +func CreateLoginObject(conn *dbus.Conn) (dbus.BusObject, error) { + obj := conn.Object( + "org.freedesktop.login1", + "/org/freedesktop/login1", + ) + + if obj == nil { + + return nil, fmt.Errorf("failed to create a login object") + } + + return obj, nil - return conn.Object("org.freedesktop.systemd1", "/org/freedesktop/systemd1") } diff --git a/internal/dbusservices/dbus/parse.go b/internal/dbusservices/dbus/parse.go index fd31e91..6f9fce0 100644 --- a/internal/dbusservices/dbus/parse.go +++ b/internal/dbusservices/dbus/parse.go @@ -2,42 +2,40 @@ package dbushandler import ( - "fmt" - svctypes "paradigm-ehb/agent/internal/dbusservices/types" + types "paradigm-ehb/agent/internal/dbusservices/types" + "strings" ) -// TODO: implement interfaces maybe - -// Method -// @param chan a(ss), chan a(ss) -// @param chan UnitFileEntry, chan UnitFileEntry -// @return nil -func ParseUnitFileEntries(in chan []svctypes.UnitFileEntry, out chan []svctypes.UnitFileEntry) { - - input := <-in - - for i := range input { - - if input[i].State == "enabled" { - fmt.Println(input[i].Name) - } else { - - fmt.Println(input[i].Name) +// TODO(nasr): implement interfaces maybe + +// ParseUnits filters units to only include services +// @param input []types.Unit +// @return []types.Unit, error +func ParseUnits(input []types.Unit) ([]types.Unit, error) { + /** + filter the units on services and remove devices etc + */ + buffer := make([]types.Unit, 0, len(input)) + for _, value := range input { + if strings.HasSuffix(value.Name, ".service") { + buffer = append(buffer, value) } } - out <- input - + return buffer, nil } -func ParseLoadedUnits(in chan []svctypes.LoadedUnit, out chan []svctypes.LoadedUnit) { - - input := <-in - - for i := range input { - - fmt.Println(input[i]) +// ParseLoadedUnits filters loaded units to only include services +// @param input []types.LoadedUnit +// @return []types.LoadedUnit, error +func ParseLoadedUnits(input []types.LoadedUnit) ([]types.LoadedUnit, error) { + /** + filter the units on services and remove devices etc + */ + buffer := make([]types.LoadedUnit, 0, len(input)) + for _, value := range input { + if strings.HasSuffix(value.Name, ".service") { + buffer = append(buffer, value) + } } - - out <- input - + return buffer, nil } diff --git a/internal/dbusservices/manager.go b/internal/dbusservices/manager.go index 9931950..c801519 100644 --- a/internal/dbusservices/manager.go +++ b/internal/dbusservices/manager.go @@ -3,18 +3,37 @@ package dbus_services import ( "fmt" - v2 "paradigm-ehb/agent/gen/services/v2" - dh "paradigm-ehb/agent/internal/dbusservices/dbus" - svc "paradigm-ehb/agent/internal/dbusservices/systemd" - svctypes "paradigm-ehb/agent/internal/dbusservices/types" + dbushelper "paradigm-ehb/agent/internal/dbusservices/dbus" + systemd "paradigm-ehb/agent/internal/dbusservices/systemd" + types "paradigm-ehb/agent/internal/dbusservices/types" "github.com/godbus/dbus" ) -// @param, action [start, stop, restart], symLinkAction [enable, disable], service name format "example.service" -func RunAction(conn *dbus.Conn, ac svc.UnitAction, service string) error { +/* +RunAction executes a systemd unit action (start, stop, restart). + +Parameters: +- conn: + Active D-Bus connection. +- ac: + Unit action to execute (start, stop, restart). +- service: + Unit name in systemd format (e.g. "example.service"). + +TODO: +- Validate service name format before invoking D-Bus. +- Propagate context / cancellation support. +- Replace raw string conversion with strongly typed D-Bus method mapping. +*/ +func RunAction( + conn *dbus.Conn, + ac systemd.UnitAction, + service string, +) error { + + obj, _ := dbushelper.CreateSystemdObject(conn) - obj := dh.CreateSystemdObject(conn) if !obj.Path().IsValid() { return fmt.Errorf("object path is invalid") } @@ -22,33 +41,86 @@ func RunAction(conn *dbus.Conn, ac svc.UnitAction, service string) error { call := obj.Call(string(ac), 0, service, "replace") if call.Err != nil { - return fmt.Errorf("failed to execute object on in unit action, %v", call.Err) + return fmt.Errorf("failed to execute object on unit action, %v", call.Err) } return nil } -// @param, action [start, stop, restart], symLinkAction [enable, disable], service name format "example.service" -func RunSymlinkAction(conn *dbus.Conn, sc svc.UnitFileAction, enableForRunTime bool, enableForce bool, service []string) error { - - obj := dh.CreateSystemdObject(conn) +/* +RunSymlinkAction executes systemd unit file actions (enable / disable). + +Parameters: +- conn: + Active D-Bus connection. +- sc: + Unit file action (enable or disable). +- enableForRunTime: + Whether the action applies only at runtime. +- enableForce: + Whether to force-enable units (only relevant for enable). +- service: + Slice of unit names. + +TODO: +- Validate service slice is non-empty. +- Clarify runtime vs persistent semantics in API naming. +- Normalize error messages. +*/ +func RunSymlinkAction( + conn *dbus.Conn, + sc systemd.UnitFileAction, + enableForRunTime bool, + enableForce bool, + service []string, +) error { + + obj, err := dbushelper.CreateSystemdObject(conn) + if err != nil { + return fmt.Errorf("failed to create systemd object %v", err) + } if !obj.Path().IsValid() { - fmt.Println("invalid systemd path") + return fmt.Errorf("invalid systemd path") } - /** EnableUnitFiles(in as files, in b runtime, in b force, out b carries_install_info, out a(sss) changes); */ - /** DisableUnitFiles(in as files, in b runtime, out a(sss) changes); */ + /* + EnableUnitFiles( + in as files, + in b runtime, + in b force, + out b carries_install_info, + out a(sss) changes + ) + + DisableUnitFiles( + in as files, + in b runtime, + out a(sss) changes + ) + */ switch sc { - case svc.UnitFileActionEnable: - call := obj.Call(string(sc), dbus.FlagAllowInteractiveAuthorization, service, enableForRunTime, enableForce) + case systemd.UnitFileActionEnable: + call := obj.Call( + string(sc), + dbus.FlagAllowInteractiveAuthorization, + service, + enableForRunTime, + enableForce, + ) if call.Err != nil { return fmt.Errorf("error %v", call.Err) } - case svc.UnitFileActionDisable: - call := obj.Call(string(sc), dbus.FlagAllowInteractiveAuthorization, service, enableForRunTime) + + case systemd.UnitFileActionDisable: + call := obj.Call( + string(sc), + dbus.FlagAllowInteractiveAuthorization, + service, + enableForRunTime, + ) if call.Err != nil { return fmt.Errorf("something happened here %v", call.Err) } @@ -57,88 +129,205 @@ func RunSymlinkAction(conn *dbus.Conn, sc svc.UnitFileAction, enableForRunTime b return nil } -// @param, true for all on disk, false for loaded units -func RunRetrieval( - conn *dbus.Conn, - all bool, -) ([]*v2.LoadedUnit, error) { - - obj := dh.CreateSystemdObject(conn) - - if all { - ch := make(chan []svctypes.UnitFileEntry) - parse := make(chan []svctypes.UnitFileEntry) - - go svc.GetAllUnits(obj, ch) - go dh.ParseUnitFileEntries(ch, parse) - - entries := <-parse - - units := make([]*v2.LoadedUnit, 0, len(entries)) - for _, e := range entries { - units = append(units, &v2.LoadedUnit{ - Name: e.Name, - Description: "", - LoadState: e.State, - SubState: "", - ActiveState: "", - DepUnit: "", - ObjectPath: "", - QueuedJob: 0, - JobType: "", - JobPath: "", - }) - } - - return units, nil +/* +UnitStatus retrieves the status of a single systemd unit. + +Parameters: +- obj: + Systemd D-Bus object. +- name: + Unit name. + +TODO: +- Replace string status with structured state representation. +- Normalize error return values. +*/ +func UnitStatus( + obj dbus.BusObject, + name string, +) (string, error) { + + out, err := systemd.GetStatusCall(obj, name) + if err != nil { + return "Failed", fmt.Errorf("failed to execute status call %v", err) } - ch := make(chan []svctypes.LoadedUnit) - parse := make(chan []svctypes.LoadedUnit) + return out, nil +} + +/* +MapLoadedUnits maps loaded systemd units to internal LoadedUnit types. - go svc.GetLoadedUnits(obj, ch) - go dh.ParseLoadedUnits(ch, parse) +TODO: +- Remove duplicated mapping logic across unit retrieval functions. +- Fix QueudJob typo once wire format compatibility is resolved. +*/ +func MapLoadedUnits(conn *dbus.Conn) ([]*types.LoadedUnit, error) { - loaded := <-parse + obj, err := dbushelper.CreateSystemdObject(conn) + if err != nil { + return nil, fmt.Errorf("failed to create systemd object: %w", err) + } - units := make([]*v2.LoadedUnit, 0, len(loaded)) - for _, u := range loaded { - units = append(units, &v2.LoadedUnit{ + loaded, err := systemd.GetLoadedUnits(obj) + if err != nil { + return nil, fmt.Errorf("failed to get loaded units: %w", err) + } + + parsed, err := dbushelper.ParseLoadedUnits(loaded) + if err != nil { + return nil, fmt.Errorf("failed to parse loaded units: %w", err) + } + + units := make([]*types.LoadedUnit, 0, len(parsed)) + for _, u := range parsed { + units = append(units, &types.LoadedUnit{ Name: u.Name, Description: u.Description, LoadState: u.LoadState, SubState: u.SubState, ActiveState: u.ActiveState, DepUnit: u.DepUnit, - ObjectPath: string(u.ObjectPath), - /*oops typo in queued job :)*/ - QueuedJob: u.QueudJob, + ObjectPath: u.ObjectPath, + QueudJob: u.QueudJob, /* keep typo for consistency */ JobType: u.JobType, - JobPath: string(u.JobPath), + JobPath: u.JobPath, }) } return units, nil } -func GetStatus(obj dbus.BusObject, name string) (string, error) { - var result string +/* +MapFilteredUnits retrieves and maps filtered systemd units. - call := obj.Call( - "org.freedesktop.systemd1.Manager.GetUnitFileState", - 0, - name, - ) +TODO: +- Replace placeholder "Not Available" strings with optional fields. +- Clarify which fields are guaranteed by GetUnitsFiltered. +*/ +func MapFilteredUnits( + conn *dbus.Conn, + filters []string, +) ([]*types.LoadedUnit, error) { - if call.Err != nil { - return "call error: ", call.Err + obj, err := dbushelper.CreateSystemdObject(conn) + if err != nil { + return nil, fmt.Errorf("failed to create systemd object: %w", err) } - if err := call.Store(&result); err != nil { - return "call store: ", err + entries, err := systemd.GetUnitsFiltered(obj, filters) + if err != nil { + return nil, fmt.Errorf("failed to get filtered units: %w", err) } - fmt.Println("status:", result) + parsed, err := dbushelper.ParseLoadedUnits(entries) + if err != nil { + return nil, fmt.Errorf("failed to parse filtered units: %w", err) + } + + units := make([]*types.LoadedUnit, 0, len(parsed)) + for _, e := range parsed { + units = append(units, &types.LoadedUnit{ + Name: e.Name, + Description: "Not available", + LoadState: e.LoadState, + SubState: "Not Available", + ActiveState: "Not Available", + DepUnit: "Not Available", + ObjectPath: "Not Available", + QueudJob: 0, + JobType: "Not Available", + JobPath: "Not Available", + }) + } + + return units, nil +} + +/* +MapUnits retrieves and maps all systemd units. + +TODO: +- Distinguish unit-on-disk vs loaded semantics at the type level. +- Avoid repeating placeholder field values. +*/ +func MapUnits(conn *dbus.Conn) ([]*types.LoadedUnit, error) { + + obj, err := dbushelper.CreateSystemdObject(conn) + if err != nil { + return nil, fmt.Errorf("failed to create systemd object: %w", err) + } + + result, err := systemd.GetUnits(obj) + if err != nil { + return nil, fmt.Errorf("failed to get units: %w", err) + } + + parsedUnits, err := dbushelper.ParseUnits(result) + if err != nil { + return nil, fmt.Errorf("failed to parse units: %w", err) + } + + units := make([]*types.LoadedUnit, 0, len(parsedUnits)) + for _, e := range parsedUnits { + units = append(units, &types.LoadedUnit{ + Name: e.Name, + Description: "Not available", + LoadState: e.State, + SubState: "Not Available", + ActiveState: "Not Available", + DepUnit: "Not Available", + ObjectPath: "Not Available", + QueudJob: 0, + JobType: "Not Available", + JobPath: "Not Available", + }) + } + + return units, nil +} + +/* +RunRetrieval retrieves units using an internally created system bus connection. + +Parameters: +- requestAllUnitsOnDisk: + true -> retrieve all units on disk + false -> retrieve only loaded units + +TODO: +- Close system bus connection explicitly. +- Propagate context. +*/ +func RunRetrieval(requestAllUnitsOnDisk bool) ([]*types.LoadedUnit, error) { + + conn, err := dbushelper.CreateSystemBus() + if err != nil { + return nil, fmt.Errorf("failed to create system bus connection: %w", err) + } + + if requestAllUnitsOnDisk { + return MapUnits(conn) + } + + return MapLoadedUnits(conn) +} + +/* +RunRetrievalDeprecated performs unit retrieval using a caller-provided connection. + +TODO: +- Remove once all call sites migrate to RunRetrieval. +- Clearly document ownership of conn lifecycle. +*/ +func RunRetrievalDeprecated( + conn *dbus.Conn, + requestAllUnitsOnDisk bool, +) ([]*types.LoadedUnit, error) { + + if requestAllUnitsOnDisk { + return MapUnits(conn) + } - return result, nil + return MapLoadedUnits(conn) } diff --git a/internal/dbusservices/systemd/list.go b/internal/dbusservices/systemd/list.go index bbf46dd..f509f56 100644 --- a/internal/dbusservices/systemd/list.go +++ b/internal/dbusservices/systemd/list.go @@ -2,7 +2,7 @@ package servicecontrol import ( "fmt" - svctypes "paradigm-ehb/agent/internal/dbusservices/types" + types "paradigm-ehb/agent/internal/dbusservices/types" "github.com/godbus/dbus" ) @@ -14,45 +14,70 @@ import ( // files (templates) cannot directly be loaded as units but need to be instantiated. // --------------------------------------------------------------------------------------- // Method returns an array of all currently loaded units, -// -func GetLoadedUnits(obj dbus.BusObject, out chan []svctypes.LoadedUnit) { - - var result []svctypes.LoadedUnit +func GetLoadedUnits(obj dbus.BusObject) ([]types.LoadedUnit, error) { + var result []types.LoadedUnit call := obj.Call("org.freedesktop.systemd1.Manager.ListUnits", 0) if call.Err != nil { - fmt.Printf("failed to list unit files that are loaded in memory %v", call.Err) - return + return nil, fmt.Errorf("failed to list unit files that are loaded in memory: %w", call.Err) } - - + err := call.Store(&result) - if err != nil { - return + return nil, fmt.Errorf("failed to store loaded units: %w", err) } - - out <- result - + + return result, nil } -func GetAllUnits(obj dbus.BusObject, out chan []svctypes.UnitFileEntry) { - +func GetUnits(obj dbus.BusObject) ([]types.Unit, error) { // ListUnitFiles(out a(ss) files); // an array of struct string string - - var result []svctypes.UnitFileEntry - + var result []types.Unit call := obj.Call("org.freedesktop.systemd1.Manager.ListUnitFiles", 0) - if call.Err != nil { - fmt.Println("failed to call all units loaded on disk") - return + return nil, fmt.Errorf("failed to call all units loaded on disk: %w", call.Err) + } + + err := call.Store(&result) + if err != nil { + return nil, fmt.Errorf("failed to store services: %w", err) } + + return result, nil +} +func GetUnitsFiltered(obj dbus.BusObject, states []string) ([]types.LoadedUnit, error) { + var result []types.LoadedUnit + call := obj.Call("org.freedesktop.systemd1.Manager.ListUnitsFiltered", 0, states) + if call.Err != nil { + return nil, fmt.Errorf("failed to call filtered list of units: %w", call.Err) + } + err := call.Store(&result) if err != nil { - return + return nil, fmt.Errorf("failed to store filtered units: %w", err) + } + + return result, nil +} + +func GetStatusCall(obj dbus.BusObject, name string) (string, error) { + var result string + + call := obj.Call( + "org.freedesktop.systemd1.Manager.GetUnitFileState", + 0, + name, + ) + + if call.Err != nil { + return "call error: ", call.Err } - out <- result + + if err := call.Store(&result); err != nil { + return "call store: ", err + } + + return result, nil } diff --git a/internal/dbusservices/types/types.go b/internal/dbusservices/types/types.go index 4f01975..83c2df1 100644 --- a/internal/dbusservices/types/types.go +++ b/internal/dbusservices/types/types.go @@ -27,7 +27,7 @@ type Service struct { } // type a(ss) -type UnitFileEntry struct { +type Unit struct { Name string State string } diff --git a/internal/journal/journal.go b/internal/journal/journal.go index e963cf2..dcbb9b0 100644 --- a/internal/journal/journal.go +++ b/internal/journal/journal.go @@ -4,34 +4,38 @@ import ( "io" "time" - "fmt" + "log" - jrnl "github.com/coreos/go-systemd/v22/journal" - sdj "github.com/coreos/go-systemd/v22/sdjournal" + "github.com/coreos/go-systemd/v22/journal" + "github.com/coreos/go-systemd/v22/sdjournal" ) -// checkJournal reports whether systemd’s journal is available and enabled -// on the current system. -// -// It is a lightweight capability check and does not open or read the journal. -// Internally, this relies on libsystemd to detect whether journald is usable -// (for example, not present on non-systemd systems). +/* +checkJournal reports whether systemd’s journal is available and enabled +on the current system. + +It is a lightweight capability check and does not open or read the journal. +Internally, this relies on libsystemd to detect whether journald is usable +(for example, not present on non-systemd systems). +*/ func checkJournal() bool { - return jrnl.Enabled() + return journal.Enabled() } -// systemdID returns the boot ID associated with the currently running system. -// -// The boot ID uniquely identifies the current boot session and is useful for -// correlating journal entries to a specific system start. The function opens -// the journal, queries the boot ID, and then closes the journal handle. -// -// If the journal cannot be opened or the boot ID cannot be retrieved, the -// returned string may be empty. +/* +systemdID returns the boot ID associated with the currently running system. + +The boot ID uniquely identifies the current boot session and is useful for +correlating journal entries to a specific system start. The function opens +the journal, queries the boot ID, and then closes the journal handle. + +If the journal cannot be opened or the boot ID cannot be retrieved, the +returned string may be empty. +*/ func systemdID() (string, error) { - j, err := sdj.NewJournal() + j, err := sdjournal.NewJournal() if err != nil { return "not available", err } @@ -43,35 +47,68 @@ func systemdID() (string, error) { } return bid, nil - } -// TODO(nasr): checkout formatters - -// GetJournalInformation GetJournaldInformation reads entries from the systemd journal and returns -// them as a single concatenated string. -// -// The journal reader is configured through the provided parameters: -// - since: limits entries to those newer than the given duration -// relative now -// - numFromTail: limits the number of entries read from the end of the journal -// - cursor: reserved for future cursor-based positioning (currently unused) -// - matches: filters entries using systemd journal match rules -// - path: optionally specifies a custom journal path -// -// Internally, this function uses a JournalReader and performs sequential reads -// into a fixed-size buffer until no more data is available or an error occurs. -// The caller receives raw journal output as text, without further parsing or -// field-level decoding. -// -// Example Matches: []sdj.Match{{Field: "_SYSTEMD_UNIT", Value: "ssh.service"}}} -func GetJournalInformation(since time.Duration, numFromTail uint64, cursor string, matches []sdj.Match, path string, out chan []byte) { - +/* +TODO(nasr): +- Evaluate and implement journal output formatters. +- Decide whether formatting should be: + - raw text (current behavior), + - structured (map / proto), + - or selectable via configuration. +*/ + +/* +GetJournalInformation reads entries from the systemd journal and streams them +through the provided output channel. + +Parameters: +- since: + Duration relative to "now" used to limit journal entries. + (Currently not implemented.) +- numFromTail: + Limits the number of entries read from the end of the journal. +- cursor: + Reserved for cursor-based positioning (currently unused). +- matches: + Journal match filters (field/value pairs). +- path: + Optional custom journal path. +- out: + Output channel receiving raw journal byte slices. + +Behavior: +- Opens a JournalReader with the provided configuration. +- Reads sequentially into a fixed-size buffer. +- Streams raw journal output without parsing or decoding fields. +- Closes the output channel before returning. + +Example: + []sdj.Match{{Field: "_SYSTEMD_UNIT", Value: "ssh.service"}} +*/ +func GetJournalInformation( + since time.Duration, + numFromTail uint64, + cursor string, + matches []sdjournal.Match, + path string, + out chan []byte, +) { + + /* + TODO(nasr): + - Define ownership and lifecycle rules for `out`. + - Document that this function is responsible for closing the channel. + */ defer close(out) - config := sdj.JournalReaderConfig{ - // TODO(nasr): fix time imlementation - //Since: since, + config := sdjournal.JournalReaderConfig{ + /* + TODO(nasr): + - Implement time-based filtering using `since`. + - Decide whether `since` should override cursor semantics. + */ + // Since: since, NumFromTail: numFromTail, Cursor: cursor, Matches: matches, @@ -79,10 +116,14 @@ func GetJournalInformation(since time.Duration, numFromTail uint64, cursor strin Formatter: nil, } - reader, err := sdj.NewJournalReader(config) - + reader, err := sdjournal.NewJournalReader(config) if err != nil { - fmt.Println("failed to open the journal reader") + /* + TODO(nasr): + - Replace stdout logging with structured error propagation. + - Decide whether to terminate early or send error markers via channel. + */ + } defer reader.Close() @@ -93,23 +134,36 @@ func GetJournalInformation(since time.Duration, numFromTail uint64, cursor strin c, err := reader.Read(b) if err == io.EOF { - fmt.Println("End of journal") + /* + TODO(nasr): + - Decide whether EOF should be silent. + - Avoid stdout logging in library code. + */ + log.Printf("end of journal %v, ", err) break } if c == 0 { - fmt.Println(" data") + /* + TODO(nasr): + - Clarify whether zero-length reads are expected. + - Remove noisy logging or replace with debug-level tracing. + */ continue } if err != nil { + /* + TODO(nasr): + - Avoid sending placeholder data on error. + - Define a structured error signaling mechanism. + - Consider context cancellation or error channels. + */ out <- []byte("nothing in here") - fmt.Println("failed to read from the journal reader", err) + log.Printf("no more data to read %v, ", err) break } - out <- b[:c] - } } diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 9323a61..54fb879 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -6,6 +6,9 @@ import ( "runtime" "runtime/debug" "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) func AssertLinux() error { @@ -15,65 +18,117 @@ func AssertLinux() error { return nil } -func RunRuntimeDiagnostics(interval time.Duration, ip string, port int) { - ticker := time.NewTicker(interval) - defer ticker.Stop() +type tickMsg time.Time - for range ticker.C { +type model struct { + ip string + port int + interval time.Duration +} - // clear screen - fmt.Print("\033[2J") - fmt.Print("\033[H") +func (m model) Init() tea.Cmd { + return tick(m.interval) +} - var m runtime.MemStats - runtime.ReadMemStats(&m) +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + case tickMsg: + return m, tick(m.interval) + } + return m, nil +} - fmt.Println("runtime diagnostics") - fmt.Println("-------------------") +func (m model) View() string { + var mem runtime.MemStats + runtime.ReadMemStats(&mem) - fmt.Printf("%-20s : %s\n", "go version", runtime.Version()) - fmt.Printf("%-20s : %s\n", "os", runtime.GOOS) - fmt.Printf("%-20s : %s\n", "architecture", runtime.GOARCH) - fmt.Printf("%-20s : %s\n", "compiler", runtime.Compiler) + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")). + MarginBottom(1) - fmt.Printf("%-20s : %d\n", "cpu cores", runtime.NumCPU()) - fmt.Printf("%-20s : %d\n", "gomaxprocs", runtime.GOMAXPROCS(0)) - fmt.Printf("%-20s : %d\n", "goroutines", runtime.NumGoroutine()) + sectionStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("86")). + MarginTop(1) - fmt.Println() - fmt.Println("memory") - fmt.Println("-----------------") + labelStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) - fmt.Printf("%-20s : %d KB\n", "heap alloc", m.HeapAlloc/1024) - fmt.Printf("%-20s : %d KB\n", "heap sys", m.HeapSys/1024) - fmt.Printf("%-20s : %d KB\n", "heap in use", m.HeapInuse/1024) - fmt.Printf("%-20s : %d KB\n", "heap idle", m.HeapIdle/1024) - fmt.Printf("%-20s : %d KB\n", "stack in use", m.StackInuse/1024) - fmt.Printf("%-20s : %d KB\n", "stack sys", m.StackSys/1024) + valueStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("white")) - fmt.Printf("%-20s : %d\n", "gc cycles", m.NumGC) - fmt.Printf("%-20s : %d ms\n", "gc pause total", m.PauseTotalNs/1e6) + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + MarginTop(1) - fmt.Printf("%-20s : %d\n", "gc next", m.NextGC/1024) + row := func(label, value string) string { + return fmt.Sprintf("%s %s", + labelStyle.Width(20).Render(label+":"), + valueStyle.Render(value)) + } - fmt.Println() - fmt.Println("server info") - fmt.Println("-----------------") - fmt.Printf("port listening on : %d\n", port) - fmt.Printf("ip address : %s\n", ip) + var s string + + s += titleStyle.Render("Runtime Diagnostics") + "\n" + + s += sectionStyle.Render("System") + "\n" + s += row("Go Version", runtime.Version()) + "\n" + s += row("OS", runtime.GOOS) + "\n" + s += row("Architecture", runtime.GOARCH) + "\n" + s += row("Compiler", runtime.Compiler) + "\n" + s += row("CPU Cores", fmt.Sprintf("%d", runtime.NumCPU())) + "\n" + s += row("GOMAXPROCS", fmt.Sprintf("%d", runtime.GOMAXPROCS(0))) + "\n" + s += row("Goroutines", fmt.Sprintf("%d", runtime.NumGoroutine())) + "\n" + + s += sectionStyle.Render("Memory") + "\n" + s += row("Heap Alloc", fmt.Sprintf("%d KB", mem.HeapAlloc/1024)) + "\n" + s += row("Heap Sys", fmt.Sprintf("%d KB", mem.HeapSys/1024)) + "\n" + s += row("Heap In Use", fmt.Sprintf("%d KB", mem.HeapInuse/1024)) + "\n" + s += row("Heap Idle", fmt.Sprintf("%d KB", mem.HeapIdle/1024)) + "\n" + s += row("Stack In Use", fmt.Sprintf("%d KB", mem.StackInuse/1024)) + "\n" + s += row("Stack Sys", fmt.Sprintf("%d KB", mem.StackSys/1024)) + "\n" + s += row("GC Cycles", fmt.Sprintf("%d", mem.NumGC)) + "\n" + s += row("GC Pause Total", fmt.Sprintf("%d ms", mem.PauseTotalNs/1e6)) + "\n" + s += row("GC Next", fmt.Sprintf("%d KB", mem.NextGC/1024)) + "\n" + + s += sectionStyle.Render("Server Info") + "\n" + s += row("IP Address", m.ip) + "\n" + s += row("Port", fmt.Sprintf("%d", m.port)) + "\n" + + if info, ok := debug.ReadBuildInfo(); ok { + s += sectionStyle.Render("Build Info") + "\n" + s += row("Module", info.Path) + "\n" + s += row("Go Version", info.GoVersion) + "\n" + if info.Main.Version != "(devel)" { + s += row("Version", info.Main.Version) + "\n" + } + } - if info, ok := debug.ReadBuildInfo(); ok { - fmt.Println() - fmt.Println("information") - fmt.Println("-----------------") + s += helpStyle.Render("\nPress 'q' or 'ctrl+c' to quit") - fmt.Printf("%-20s : %s\n", "module", info.Path) - fmt.Printf("%-20s : %s\n", "go version", info.GoVersion) + return s +} - if info.Main.Version != "(devel)" { - fmt.Printf("%-20s : %s\n", "version", info.Main.Version) - } +func tick(interval time.Duration) tea.Cmd { + return tea.Tick(interval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} - } +func RunRuntimeDiagnostics(interval time.Duration, ip string, port int) { + p := tea.NewProgram(model{ + ip: ip, + port: port, + interval: interval, + }) + + if _, err := p.Run(); err != nil { + fmt.Printf("Error running diagnostics: %v\n", err) } } diff --git a/pkg/agent-resources b/pkg/agent-resources index 5dc30a0..01f4420 160000 --- a/pkg/agent-resources +++ b/pkg/agent-resources @@ -1 +1 @@ -Subproject commit 5dc30a052160c8234393641db3521b59e26f4fb9 +Subproject commit 01f44207854e794f162bb23953bb4dcf45f1b154 diff --git a/pkg/cgowrap/types.go b/pkg/cgowrap/types.go index 0b6a8e2..30d8492 100644 --- a/pkg/cgowrap/types.go +++ b/pkg/cgowrap/types.go @@ -6,12 +6,14 @@ type Cpu struct { Model string Frequency string MaxCore uint32 + TotalTime uint64 + IdleTime uint64 } // Ram represents RAM information including total and free memory. type Ram struct { - Total string - Free string + Total uint64 + Free uint64 } // DiskPartition represents a single disk partition with device identifiers and block count. @@ -33,48 +35,4 @@ type Device struct { Uptime string } -// ProcessState represents the state of a process. -type ProcessState uint32 -const ( - ProcessUndefined ProcessState = 0 - ProcessRunning ProcessState = 1 - ProcessSleeping ProcessState = 2 - ProcessDiskSleep ProcessState = 3 - ProcessStopped ProcessState = 4 - ProcessTracingStopped ProcessState = 5 - ProcessZombie ProcessState = 6 - ProcessDead ProcessState = 7 -) - -// String returns a human-readable representation of the process state. -func (ps ProcessState) String() string { - switch ps { - case ProcessRunning: - return "Running" - case ProcessSleeping: - return "Sleeping" - case ProcessDiskSleep: - return "Disk Sleep" - case ProcessStopped: - return "Stopped" - case ProcessTracingStopped: - return "Tracing Stopped" - case ProcessZombie: - return "Zombie" - case ProcessDead: - return "Dead" - default: - return "Undefined" - } -} - -// Process represents a single process with its attributes. -type Process struct { - PID uint32 - Name string - State ProcessState - UTime uint64 - STime uint64 - NumThreads uint32 -} diff --git a/pkg/cgowrap/wrapper.go b/pkg/cgowrap/wrapper.go index 4283da8..034d9a0 100644 --- a/pkg/cgowrap/wrapper.go +++ b/pkg/cgowrap/wrapper.go @@ -3,7 +3,131 @@ package wrapper /* #cgo CFLAGS: -I${SRCDIR}/../agent-resources #cgo LDFLAGS: -L${SRCDIR}/../agent-resources/build -lagent_resources + +#include +#include +#include #include "resources.h" + +int +process_read2(i32 pid, Process *out) +{ + char path[PATH_MAX_LEN]; + snprintf(path, sizeof(path), "/proc/%d/status", pid); + + FILE *fp = fopen(path, "r"); + if (!fp) + { + return ERR_IO; + } + + char buf[BUFFER_SIZE_LARGE]; + + out->pid = pid; + + while (fgets( + buf, + sizeof(buf), + fp)) + { + char *colon = strchr(buf, ':'); + if (!colon) + { + continue; + } + + char *val = colon + 1; + while (*val == ' ' || *val == '\t') + { + ++val; + } + + size_t len = strcspn(val, "\n"); + + + if (!strncmp(buf, "Name:", 5)) + { + + memcpy(out->name, val, len); + } + if (!strncmp(buf, "State:", 6)) + { + char state_char = 0; + for (char *p = val; *p; ++p) + { + if (*p >= 'A' && *p <= 'Z' || *p == 't') + { + state_char = *p; + break; + } + } + + + switch (state_char) + { + case 'R': + { + out->state = PROCESS_RUNNING; + break; + } + case 'S': + { + out->state = PROCESS_SLEEPING; + break; + } + case 'D': + { + out->state = PROCESS_DISK_SLEEP; + break; + } + case 'T': + { + out->state = PROCESS_STOPPED; + break; + } + case 't': + { + out->state = PROCESS_TRACING_STOPPED; + break; + } + case 'Z': + { + out->state = PROCESS_ZOMBIE; + break; + } + case 'X': + { + out->state = PROCESS_DEAD; + break; + } + case 'I': + { + out->state = PROCESS_IDLE; + break; + } + default: + { + out->state = PROCESS_UNDEFINED; + break; + } + } + } + + if (!strncmp(buf, "Threads:", 8)) + { + out->num_threads = (u32)strtoul(val, 0, 10); + } + } + + + int error = fclose(fp); + if (error != 0) + { + return ERR_IO; + } + + return OK; +} */ import "C" import ( @@ -13,6 +137,56 @@ import ( "golang.org/x/sys/unix" ) +// ProcessState represents the state of a process. + +type ProcessState C.int32_t + +const ( + ProcessUndefined ProcessState = ProcessState(C.PROCESS_UNDEFINED) + ProcessRunning ProcessState = ProcessState(C.PROCESS_RUNNING) + ProcessSleeping ProcessState = ProcessState(C.PROCESS_SLEEPING) + ProcessDiskSleep ProcessState = ProcessState(C.PROCESS_DISK_SLEEP) + ProcessStopped ProcessState = ProcessState(C.PROCESS_STOPPED) + ProcessTracingStopped ProcessState = ProcessState(C.PROCESS_TRACING_STOPPED) + ProcessZombie ProcessState = ProcessState(C.PROCESS_ZOMBIE) + ProcessDead ProcessState = ProcessState(C.PROCESS_DEAD) + ProcessIdle ProcessState = ProcessState(C.PROCESS_IDLE) +) + +// Process represents a single process with its attributes. +type Process struct { + PID int32 + State ProcessState + UTime uint64 + STime uint64 + NumThreads uint32 + Name string +} + +func (s ProcessState) String() string { + + switch s { + case ProcessRunning: + return "Running" + case ProcessSleeping: + return "Sleeping" + case ProcessDiskSleep: + return "Disk Sleep" + case ProcessStopped: + return "Stopped" + case ProcessTracingStopped: + return "Tracing Stopped" + case ProcessZombie: + return "Zombie" + case ProcessDead: + return "Dead" + case ProcessIdle: + return "Idle" + default: + return "Undefined" + } +} + // TODO(nasr): research this, interesting, alias vs true aliasing type Arena = C.mem_arena @@ -59,6 +233,14 @@ func AllocateArena(size uint64) (*C.mem_arena, error) { return arena, nil } +func PushArena(arena *C.mem_arena, size uint64) { + + if arena != nil { + C.arena_push(arena, C.ulong(size), 1) + } + +} + /* * DestroyArena unmaps and destroys the memory arena. @@ -127,11 +309,17 @@ func CpuRead(c *C.Cpu) (Cpu, error) { return Cpu{}, fmt.Errorf("failed to read CPU information") } + if C.cpu_read_usage(c) != C.OK { + return Cpu{}, fmt.Errorf("failed to read CPU information") + } + cpu := Cpu{ Vendor: C.GoString(&c.vendor[0]), Model: C.GoString(&c.model[0]), Frequency: C.GoString(&c.frequency[0]), MaxCore: uint32(c.cores), + TotalTime: uint64(c.total_time), + IdleTime: uint64(c.idle_time), } return cpu, nil } @@ -176,9 +364,10 @@ func RamRead(ram *C.Ram) (Ram, error) { } r := Ram{ - Total: C.GoString(&ram.total[0]), - Free: C.GoString(&ram.free[0]), + Total: uint64(ram.total), + Free: uint64(ram.free), } + return r, nil } @@ -224,10 +413,10 @@ func DiskRead(disk *C.Disk) (Disk, error) { } d := Disk{ - Partitions: make([]DiskPartition, 0, disk.count), + Partitions: make([]DiskPartition, 0, disk.part_count), } - for i := C.size_t(0); i < disk.count; i++ { + for i := C.size_t(0); i < disk.part_count; i++ { part := (*C.Partition)( unsafe.Pointer( uintptr(unsafe.Pointer(disk.partitions)) + @@ -334,39 +523,34 @@ Returns: - error: Error if device pointer is nil */ func ReadProcesses(device *C.Device) ([]Process, error) { + if device == nil { - return nil, fmt.Errorf("nil Device pointer") + return nil, fmt.Errorf("dvice null pointer") } - procs := make([]Process, 0, device.processes.count) + count := int(device.processes.count) items := device.processes.items - for i := C.size_t(0); i < device.processes.count; i++ { - p := (*C.Process)( - unsafe.Pointer( - uintptr(unsafe.Pointer(items)) + - uintptr(i)*unsafe.Sizeof(*items), - ), - ) + procs := make([]Process, 0, count) + + for i := 0; i < count; i++ { + + p := (*C.Process)(unsafe.Pointer(uintptr(unsafe.Pointer(items)) + uintptr(i)*unsafe.Sizeof(*items))) - /** - Read detailed process information - Skip processes that can't be read - */ - if C.process_read(p.pid, p) != C.OK { - continue + err := C.process_read2(p.pid, p) + if err != C.OK { + return nil, fmt.Errorf("failed reading processes %v", err) } procs = append(procs, Process{ - PID: uint32(p.pid), - Name: C.GoString(&p.name[0]), + PID: int32(p.pid), State: ProcessState(p.state), UTime: uint64(p.utime), STime: uint64(p.stime), NumThreads: uint32(p.num_threads), + Name: C.GoString(&p.name[0]), }) } - return procs, nil } @@ -380,12 +564,13 @@ Parameters: Returns: - error: Error if the process cannot be killed or doesn't exist */ -func KillProcess(pid int) error { +func ProcessAction(pid int, action unix.Signal) error { + if pid <= 0 { return fmt.Errorf("invalid PID: %d", pid) } - if C.process_kill(C.int(pid), C.int(unix.SIGTERM)) != C.OK { + if C.process_kill(C.int(pid), C.int(action)) != C.OK { return fmt.Errorf("failed to kill process %d", pid) } return nil diff --git a/pkg/grpc_handler/journal.go b/pkg/grpc_handler/journal.go deleted file mode 100644 index 8f60f58..0000000 --- a/pkg/grpc_handler/journal.go +++ /dev/null @@ -1,68 +0,0 @@ -package grpc_handler - -import ( - "log" - "paradigm-ehb/agent/gen/journal/v1" - j "paradigm-ehb/agent/internal/journal" - "sync" - - "github.com/coreos/go-systemd/v22/sdjournal" -) - -type JournalService struct { - journal.UnimplementedJournalServiceServer -} - -func (s *JournalService) Action(in *journal.JournalRequest, srv journal.JournalService_ActionServer) error { - - var val string - // TODO(nasr): remove the magic number enums, horrible code practice - switch in.Field { - - case 0: - val = sdjournal.SD_JOURNAL_FIELD_SYSTEMD_UNIT - case 1: - val = sdjournal.SD_JOURNAL_FIELD_PID - case 2: - val = sdjournal.SD_JOURNAL_FIELD_UID - case 3: - val = sdjournal.SD_JOURNAL_FIELD_GID - } - - m := []sdjournal.Match{{Field: val, Value: in.Value}} - - // Generated time. testing issue - // TODO(nasr): fix the time - //sinceTime, err := time.Parse(time.RFC3339, "0") - // - //if err != nil { - // fmt.Println("error parsing time:", err) - // return nil, nil - //} - // - //duration := time.Since(sinceTime) - - var wg sync.WaitGroup - - ch := make(chan []byte) - - wg.Add(2) - go func() { - defer wg.Done() - j.GetJournalInformation(0, in.NumFromTail, in.Cursor, m, in.Path, ch) - }() - - go func() { - defer wg.Done() - - for range ch { - resp := journal.JournalChunk{Reply: <-ch} - if err := srv.Send(&resp); err != nil { - log.Printf("send error %v", err) - } - } - }() - - wg.Wait() - return nil -} diff --git a/pkg/grpc_handler/deviceactions.go b/pkg/grpchandler/deviceactions.go similarity index 91% rename from pkg/grpc_handler/deviceactions.go rename to pkg/grpchandler/deviceactions.go index baf9b58..334b2eb 100644 --- a/pkg/grpc_handler/deviceactions.go +++ b/pkg/grpchandler/deviceactions.go @@ -25,18 +25,13 @@ func (s *DeviceActionsService) Action(ctx context.Context, req *actions.ActionRe } var out string - bus, err := dh.CreateSystemBus() + conn, err := dh.CreateSystemBus() if err != nil { log.Println("failed to create systembus") out = "failed system bus" } - - obj := bus.Object( - "org.freedesktop.login1", - "/org/freedesktop/login1", - ) - + obj, _ := dh.CreateLoginObject(conn) switch req.GetDeviceAction() { case actions.DeviceAction_DEVICE_ACTION_SHUTDOWN: out = string(da.PerformDeviceAction(obj, da.DeviceActionShutdown)) diff --git a/pkg/grpc_handler/greeter.go b/pkg/grpchandler/greeter.go similarity index 100% rename from pkg/grpc_handler/greeter.go rename to pkg/grpchandler/greeter.go diff --git a/pkg/grpchandler/journal.go b/pkg/grpchandler/journal.go new file mode 100644 index 0000000..7bf9801 --- /dev/null +++ b/pkg/grpchandler/journal.go @@ -0,0 +1,103 @@ +package grpc_handler + +import ( + "log" + "paradigm-ehb/agent/gen/journal/v1" + j "paradigm-ehb/agent/internal/journal" + "sync" + + "github.com/coreos/go-systemd/v22/sdjournal" +) + +type JournalService struct { + journal.UnimplementedJournalServiceServer +} + +func (s *JournalService) Action(in *journal.JournalRequest, srv journal.JournalService_ActionServer) error { + + var val string + /* + TODO(nasr): + - Remove magic-number–based enum handling. + - Replace `switch in.Field` with: + - a typed protobuf enum, or + - a map[JournalField]string lookup, or + - explicit constants with semantic names. + - Ensure invalid enum values are handled (default case + error). + */ + switch in.Field { + + case 0: + val = sdjournal.SD_JOURNAL_FIELD_SYSTEMD_UNIT + case 1: + val = sdjournal.SD_JOURNAL_FIELD_PID + case 2: + val = sdjournal.SD_JOURNAL_FIELD_UID + case 3: + val = sdjournal.SD_JOURNAL_FIELD_GID + } + + m := []sdjournal.Match{{Field: val, Value: in.Value}} + + /* + TODO(nasr): + - Implement proper time filtering support. + - Decide on API semantics: + - absolute timestamp vs relative duration + - server-side vs client-provided time window + - Validate time parsing errors and propagate them via gRPC status. + - Remove dead/commented-out code once design is finalized. + */ + + var wg sync.WaitGroup + + ch := make(chan []byte) + + /* + TODO(nasr): + - Define clear channel ownership: + - who closes `ch` and when. + - Consider making channel buffered to avoid producer/consumer blocking. + - Document lifetime guarantees between goroutines. + */ + + wg.Add(2) + go func() { + defer wg.Done() + /* + TODO(nasr): + - Propagate context cancellation into GetJournalInformation. + - Return errors instead of silent failure. + - Clarify meaning of leading `0` argument. + */ + j.GetJournalInformation(0, in.NumFromTail, in.Cursor, m, in.Path, ch) + }() + + go func() { + defer wg.Done() + + /* + TODO(nasr): + - Fix channel consumption logic: + current code ranges over `ch` but also performs `<-ch` again. + - Avoid double reads from the same channel. + - Handle channel close explicitly. + - Propagate send errors via context cancellation or status return. + */ + for range ch { + resp := journal.JournalChunk{Reply: <-ch} + if err := srv.Send(&resp); err != nil { + log.Printf("send error %v", err) + } + } + }() + + /* + TODO(nasr): + - Consider early exit on client disconnect. + - Avoid waiting indefinitely if goroutines deadlock. + - Evaluate replacing WaitGroup + channels with errgroup + context. + */ + wg.Wait() + return nil +} diff --git a/pkg/grpc_handler/resources.go b/pkg/grpchandler/resources/v1/resources.go similarity index 98% rename from pkg/grpc_handler/resources.go rename to pkg/grpchandler/resources/v1/resources.go index 25f2117..0a60456 100644 --- a/pkg/grpc_handler/resources.go +++ b/pkg/grpchandler/resources/v1/resources.go @@ -97,8 +97,8 @@ func mapCPU(c wr.Cpu) *proto.Cpu { */ func mapMemory(m wr.Ram) *proto.Memory { return &proto.Memory{ - Total: m.Total, - Free: m.Free, + Total: string(m.Total), + Free: string(m.Free), } } @@ -152,7 +152,7 @@ func mapProcesses(ps []wr.Process) []*proto.Process { for _, p := range ps { out = append(out, &proto.Process{ - Pid: p.PID, + Pid: uint32(p.PID), Name: p.Name, State: mapProcessState(p.State), Utime: p.UTime, diff --git a/pkg/grpchandler/resources/v2/resources.go b/pkg/grpchandler/resources/v2/resources.go new file mode 100644 index 0000000..5da3509 --- /dev/null +++ b/pkg/grpchandler/resources/v2/resources.go @@ -0,0 +1,265 @@ +package grpc_handler + +import ( + "context" + + proto "paradigm-ehb/agent/gen/resources/v2" + "paradigm-ehb/agent/internal/resources" + cgo "paradigm-ehb/agent/pkg/cgowrap" + + "golang.org/x/sys/unix" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +/** + * ResourcesServiceV2 implements the gRPC ResourcesServiceServer. + * + * It exposes endpoints that provide a snapshot of system-level resources + * such as CPU, memory, disks, and running processes. All data collection + * is delegated to the internal resources package and then mapped to + * protobuf-defined response types. + */ +type ResourcesServiceV2 struct { + proto.UnimplementedResourcesServiceServer +} + +/** + * GetSystemResources returns a full snapshot of system resources. + * + * The method: + * - Respects context cancellation to avoid unnecessary work + * - Collects system information via the internal resources layer + * - Maps internal domain structures to protobuf response messages + * + * Errors: + * - Returns codes.Canceled if the request context is canceled + * - Returns codes.Internal if resource collection fails + */ +func (s *ResourcesServiceV2) GetSystemResources( + ctx context.Context, + req *proto.GetSystemResourcesRequest, +) (*proto.GetSystemResourcesResponse, error) { + + select { + case <-ctx.Done(): + return nil, status.Error(codes.Canceled, "request canceled") + default: + } + + snap, err := resources.GetCompleteSystemResources() + if err != nil { + return nil, status.Errorf( + codes.Internal, + "failed to collect system resources: %v", + err, + ) + } + + return &proto.GetSystemResourcesResponse{ + Resources: mapSystemResources(snap), + }, nil +} + +/** +* TODO(nasr): update individual resources + */ + +func (s *ResourcesServiceV2) ProcessAction( + ctx context.Context, + req *proto.ProcessActionRequest, +) (*proto.ProcessActionReply, error) { + + err := cgo.ProcessAction(int(req.Pid), unix.Signal(req.Signal)) + if err != nil { + + return &proto.ProcessActionReply{ + Succes: false, + }, nil + } + + return &proto.ProcessActionReply{ + Succes: true, + }, nil +} + +/** + * mapSystemResources converts an internal SystemResources snapshot + * into its protobuf representation. + * + * This acts as the top-level aggregation mapper, delegating + * to more specific mapping functions per subsystem. + */ +func mapSystemResources(s *resources.SystemResources) *proto.SystemResources { + + return &proto.SystemResources{ + Cpu: mapCPU(s.CPU), + Memory: mapMemory(s.Memory), + Device: mapDevice(s.Device), + Disks: mapDisks(s.Disks), + Processes: mapProcesses(s.Procs), + } +} + +/** + * mapCPU maps CPU metadata from the cgo wrapper type + * into the protobuf Cpu message. + */ +func mapCPU(c cgo.Cpu) *proto.Cpu { + + return &proto.Cpu{ + Vendor: c.Vendor, + Model: c.Model, + Frequency: c.Frequency, + MaxCore: c.MaxCore, + TotalTime: c.TotalTime, + IdleTime: c.IdleTime, + } + +} + +/** + * mapMemory maps RAM usage information into the protobuf Memory message. + * + * Values are expected to be raw byte counts as reported by the system. + */ +func mapMemory(m cgo.Ram) *proto.Memory { + + return &proto.Memory{ + Total: m.Total, + Free: m.Free, + } + +} + +/** + * mapDevice maps general device and OS-level metadata + * into the protobuf Device message. + */ +func mapDevice(d cgo.Device) *proto.Device { + + return &proto.Device{ + OsVersion: d.OsVersion, + Uptime: d.Uptime, + } + +} + +/** + * mapDisks maps a slice of disk descriptors into protobuf Disk messages. + * + * Each disk contains a list of partitions, which are also converted + * field-by-field into their protobuf equivalents. + */ +func mapDisks(disks []cgo.Disk) []*proto.Disk { + + out := make([]*proto.Disk, 0, len(disks)) + + for _, d := range disks { + + parts := make([]*proto.DiskPartition, 0, len(d.Partitions)) + + for _, p := range d.Partitions { + + parts = append(parts, &proto.DiskPartition{ + + Name: p.Name, + Major: p.Major, + Minor: p.Minor, + Blocks: p.Blocks, + }) + } + + out = append(out, &proto.Disk{ + Partitions: parts, + }) + } + + return out +} + +/** + * mapProcessState converts an internal ProcessState enum + * into the corresponding protobuf ProcessState value. + * + * Unknown or unmapped states are converted to PROCESS_STATE_UNSPECIFIED + * to preserve forward compatibility. + */ +func mapProcessState(s cgo.ProcessState) proto.ProcessState { + + switch s { + + case cgo.ProcessRunning: + { + + return proto.ProcessState_PROCESS_STATE_RUNNING + } + case cgo.ProcessSleeping: + { + + return proto.ProcessState_PROCESS_STATE_SLEEPING + } + case cgo.ProcessDiskSleep: + { + + return proto.ProcessState_PROCESS_STATE_DISK_SLEEPING + } + case cgo.ProcessStopped: + { + + return proto.ProcessState_PROCESS_STATE_STOPPED + } + case cgo.ProcessTracingStopped: + { + + return proto.ProcessState_PROCESS_STATE_TRACING_STOPPED + } + case cgo.ProcessZombie: + { + + return proto.ProcessState_PROCESS_STATE_ZOMBIE + } + case cgo.ProcessDead: + { + + return proto.ProcessState_PROCESS_STATE_DEAD + } + case cgo.ProcessIdle: + { + return proto.ProcessState_PROCESS_STATE_IDLE + } + + case cgo.ProcessUndefined: + { + return proto.ProcessState_PROCESS_STATE_UNSPECIFIED + + } + default: + + return proto.ProcessState_PROCESS_STATE_UNSPECIFIED + } +} + +/** + * mapProcesses maps a slice of process descriptors into protobuf Process messages. + * + * Each process includes basic scheduling and accounting information + * such as PID, state, CPU time, and thread count. + */ +func mapProcesses(ps []cgo.Process) []*proto.Process { + + out := make([]*proto.Process, 0, len(ps)) + + for _, p := range ps { + out = append(out, &proto.Process{ + Pid: int32(p.PID), + Name: p.Name, + State: mapProcessState(p.State), + Utime: p.UTime, + NumThreads: p.NumThreads, + }) + } + + return out +} diff --git a/pkg/grpc_handler/services.go b/pkg/grpchandler/services/v1/services.go similarity index 97% rename from pkg/grpc_handler/services.go rename to pkg/grpchandler/services/v1/services.go index 719e0ed..ef1e423 100644 --- a/pkg/grpc_handler/services.go +++ b/pkg/grpchandler/services/v1/services.go @@ -91,7 +91,7 @@ func (s *HandlerService) UnitAction(_ context.Context, in *v1.ServiceActionReque out = "external bad input" } - _, err = manager.RunRetrieval(conn, true) + _, err = manager.RunRetrievalDeprecated(conn, true) if err != nil { log.Println("failed to do everything") out = "failed even more" diff --git a/pkg/grpc_handler/services_v2.go b/pkg/grpchandler/services/v2/services.go similarity index 87% rename from pkg/grpc_handler/services_v2.go rename to pkg/grpchandler/services/v2/services.go index a7549a7..6ede873 100644 --- a/pkg/grpc_handler/services_v2.go +++ b/pkg/grpchandler/services/v2/services.go @@ -128,7 +128,13 @@ func (s *HandlerServiceV2) GetAllUnits( }, nil } - units, err := manager.RunRetrieval(conn, true) + /** + + TODO(nasr): fix the design issue and return the correct mapped types + from the correct namespace + + */ + _, err = manager.RunRetrievalDeprecated(conn, true) if err != nil { return &v2.GetUnitsReply{ Success: false, @@ -137,7 +143,7 @@ func (s *HandlerServiceV2) GetAllUnits( } return &v2.GetUnitsReply{ - Units: units, + Units: nil, Success: true, }, nil } @@ -154,8 +160,13 @@ func (s *HandlerServiceV2) GetLoadedUnits( ErrorMessage: err.Error(), }, nil } + /** + + TODO(nasr): fix the design issue and return the correct mapped types + from the correct namespace - units, err := manager.RunRetrieval(conn, false) + */ + _, err = manager.RunRetrievalDeprecated(conn, false) if err != nil { return &v2.GetUnitsReply{ Success: false, @@ -164,7 +175,7 @@ func (s *HandlerServiceV2) GetLoadedUnits( } return &v2.GetUnitsReply{ - Units: units, + Units: nil, Success: true, }, nil } @@ -183,9 +194,12 @@ func (s *HandlerServiceV2) GetUnitStatus( }, nil } - obj := dh.CreateSystemdObject(conn) + obj, err := dh.CreateSystemdObject(conn) + if err != nil { + return nil, fmt.Errorf("failed to create system object") + } - state, err := manager.GetStatus(obj, in.UnitName) + state, err := manager.UnitStatus(obj, in.UnitName) if err != nil { return &v2.GetUnitStatusReply{ Success: false, diff --git a/pkg/grpchandler/services/v3/services.go b/pkg/grpchandler/services/v3/services.go new file mode 100644 index 0000000..d569141 --- /dev/null +++ b/pkg/grpchandler/services/v3/services.go @@ -0,0 +1,297 @@ +package grpc_handler + +import ( + "context" + "fmt" + + v3 "paradigm-ehb/agent/gen/services/v3" + + manager "paradigm-ehb/agent/internal/dbusservices" + dh "paradigm-ehb/agent/internal/dbusservices/dbus" + servicecontrol "paradigm-ehb/agent/internal/dbusservices/systemd" + types "paradigm-ehb/agent/internal/dbusservices/types" +) + +type HandlerServicev3 struct { + v3.UnimplementedHandlerServiceServer +} + +func toGrpcUnits(units []*types.LoadedUnit) ([]*v3.LoadedUnit, error) { + + if units == nil { + return nil, fmt.Errorf("passed input is nil") + } + + + out := make([]*v3.LoadedUnit, 0, len(units)) + + for _, u := range units { + if u == nil { + continue + } + + out = append(out, &v3.LoadedUnit{ + Name: u.Name, + Description: u.Description, + LoadState: u.LoadState, + SubState: u.SubState, + ActiveState: u.ActiveState, + DepUnit: u.DepUnit, + ObjectPath: string(u.ObjectPath), + QueuedJob: u.QueudJob, + JobType: u.JobType, + JobPath: string(u.JobPath), + }) + } + + + return out, nil +} +func (s *HandlerServicev3) PerformUnitAction( + _ context.Context, + in *v3.UnitActionRequest, +) (*v3.UnitActionReply, error) { + + conn, err := dh.CreateSystemBus() + if err != nil { + return &v3.UnitActionReply{ + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + var action servicecontrol.UnitAction + var actionName string + + switch in.Action { + case v3.UnitActionRequest_UNIT_ACTION_START: + action = servicecontrol.UnitActionStart + actionName = "start" + case v3.UnitActionRequest_UNIT_ACTION_STOP: + action = servicecontrol.UnitActionStop + actionName = "stop" + case v3.UnitActionRequest_UNIT_ACTION_RESTART: + action = servicecontrol.UnitActionRestart + actionName = "restart" + default: + return &v3.UnitActionReply{ + Success: false, + ErrorMessage: "unspecified unit action", + }, nil + } + + if err := manager.RunAction(conn, action, in.UnitName); err != nil { + return &v3.UnitActionReply{ + Status: []byte(fmt.Sprintf("failed to %s unit", actionName)), + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + return &v3.UnitActionReply{ + Status: []byte("success"), + Success: true, + }, nil +} + +func (s *HandlerServicev3) PerformUnitFileAction( + _ context.Context, + in *v3.UnitFileActionRequest, +) (*v3.UnitFileActionReply, error) { + + conn, err := dh.CreateSystemBus() + if err != nil { + return &v3.UnitFileActionReply{ + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + var action servicecontrol.UnitFileAction + + switch in.Action { + case v3.UnitFileActionRequest_UNIT_FILE_ACTION_ENABLE: + action = servicecontrol.UnitFileActionEnable + case v3.UnitFileActionRequest_UNIT_FILE_ACTION_DISABLE: + action = servicecontrol.UnitFileActionDisable + default: + return &v3.UnitFileActionReply{ + Success: false, + ErrorMessage: "unspecified unit file action", + }, nil + } + + if err := manager.RunSymlinkAction( + conn, + action, + in.Runtime, + in.Force, + []string{in.UnitName}, + ); err != nil { + return &v3.UnitFileActionReply{ + Status: []byte("unit file action failed"), + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + return &v3.UnitFileActionReply{ + Status: []byte("success"), + Success: true, + }, nil +} + +func (s *HandlerServicev3) GetAllUnits( + _ context.Context, + _ *v3.GetUnitsRequest, +) (*v3.GetUnitsReply, error) { + + units, err := manager.RunRetrieval(true) + if err != nil { + return &v3.GetUnitsReply{ + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + + mapped, err := toGrpcUnits(units) + if err != nil { + return &v3.GetUnitsReply{ + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + + return &v3.GetUnitsReply{ + Units: mapped, + Success: true, + }, nil +} + +func (s *HandlerServicev3) GetLoadedUnits( + _ context.Context, + _ *v3.GetUnitsRequest, +) (*v3.GetUnitsReply, error) { + + in, err := manager.RunRetrieval(false) + + if err != nil { + + return &v3.GetUnitsReply{ + Units: nil, + Success: true, + }, fmt.Errorf("failed to run retrieval") + } + + out, err := toGrpcUnits(in) + + if err != nil { + + return &v3.GetUnitsReply{ + Units: nil, + Success: true, + }, fmt.Errorf("failed to map to grpc types") + } + + return &v3.GetUnitsReply{ + Units: out, + Success: true, + }, nil +} + +func (s *HandlerServicev3) GetFilteredUnits( + _ context.Context, + in *v3.GetUnitsFilteredRequest, +) (*v3.GetUnitsReply, error) { + + conn, err := dh.CreateSystemBus() + if err != nil { + return &v3.GetUnitsReply{ + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + filters := make([]string, 0, len(in.Filters)) + + for _, f := range in.Filters { + switch f { + case v3.GetUnitsFilteredRequest_LOADED: + filters = append(filters, "loaded") + case v3.GetUnitsFilteredRequest_NOT_FOUND: + filters = append(filters, "not-found") + case v3.GetUnitsFilteredRequest_BAD_SETTING: + filters = append(filters, "bad-setting") + case v3.GetUnitsFilteredRequest_ERROR: + filters = append(filters, "error") + case v3.GetUnitsFilteredRequest_MASKED: + filters = append(filters, "masked") + default: + return &v3.GetUnitsReply{ + Success: false, + ErrorMessage: "unspecified filter state", + }, nil + } + } + + units, err := manager.MapFilteredUnits(conn, filters) + if err != nil { + return &v3.GetUnitsReply{ + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + result, err := toGrpcUnits(units) + + fmt.Print("result: ", result) + + if err != nil { + return &v3.GetUnitsReply{ + Units: nil, + Success: false, + }, fmt.Errorf("failed to parse to grpc units") + } + + return &v3.GetUnitsReply{ + Units: result, + Success: true, + }, nil +} + +func (s *HandlerServicev3) GetUnitStatus( + _ context.Context, + in *v3.GetUnitStatusRequest, +) (*v3.GetUnitStatusReply, error) { + + conn, err := dh.CreateSystemBus() + if err != nil { + return &v3.GetUnitStatusReply{ + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + obj, err := dh.CreateSystemdObject(conn) + if err != nil { + return &v3.GetUnitStatusReply{ + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + state, err := manager.UnitStatus(obj, in.UnitName) + if err != nil { + return &v3.GetUnitStatusReply{ + Success: false, + ErrorMessage: err.Error(), + }, nil + } + + return &v3.GetUnitStatusReply{ + State: state, + Success: true, + }, nil +} diff --git a/proto/resources/v1/device_resources.proto b/proto/resources/v1/deviceresources.proto similarity index 98% rename from proto/resources/v1/device_resources.proto rename to proto/resources/v1/deviceresources.proto index 667c92a..63e6087 100644 --- a/proto/resources/v1/device_resources.proto +++ b/proto/resources/v1/deviceresources.proto @@ -92,6 +92,7 @@ enum ProcessState { PROCESS_STATE_TRACING_STOPPED = 5; PROCESS_STATE_ZOMBIE = 6; PROCESS_STATE_DEAD = 7; + PROCESS_STATE_IDLE = 8; } /** diff --git a/proto/resources/v2/deviceresources.proto b/proto/resources/v2/deviceresources.proto new file mode 100644 index 0000000..13d2712 --- /dev/null +++ b/proto/resources/v2/deviceresources.proto @@ -0,0 +1,124 @@ +syntax = "proto3"; + +package resources.v2; + +option go_package = "paradigm-ehb/agent/gen/resources/v2;resourcespb"; + +service ResourcesService { + rpc GetSystemResources(GetSystemResourcesRequest) + returns (GetSystemResourcesResponse); + rpc ProcessAction(ProcessActionRequest) + returns (ProcessActionReply); + +} + +message GetSystemResourcesRequest {} + +message GetSystemResourcesResponse { + SystemResources resources = 1; +} + +/** + * + * Get a complete system snapshots + * @return Cpu, Memory, Device, Disk ( partitions ) and processes + * + * */ +message SystemResources { + Cpu cpu = 1; + Memory memory = 2; + Device device = 3; + repeated Disk disks = 4; + repeated Process processes = 5; +} + +/** + * Cpu information + * + * @return vendor, model, frequency, cores + * */ +message Cpu { + string vendor = 1; + string model = 2; + string frequency = 3; + uint32 max_core = 4; + uint64 total_time = 5; + uint64 idle_time = 6; +} + +/** + * Memory + * @return total memory on system, available memory on system + * */ +message Memory { + uint64 total = 1; + uint64 free = 2; +} + +/** + * Device specific information + * @return os version ( distro information ), and uptime in string format + * + * */ + +message Device { + string os_version = 1; + string uptime = 2; +} + +/** + * + * Disk information + * @disk a list of all available partitions + * + * */ + +message Disk { + repeated DiskPartition partitions = 1; +} + +/** + * Partition + * @return partition name, major, minor, blocks + * */ +message DiskPartition { + string name = 1; + uint32 major = 2; + uint32 minor = 3; + uint64 blocks = 4; +} + +enum ProcessState { + PROCESS_STATE_UNSPECIFIED = 0; + PROCESS_STATE_RUNNING = 1; + PROCESS_STATE_SLEEPING = 2; + PROCESS_STATE_DISK_SLEEPING = 3; + PROCESS_STATE_STOPPED = 4; + PROCESS_STATE_TRACING_STOPPED = 5; + PROCESS_STATE_ZOMBIE = 6; + PROCESS_STATE_DEAD = 7; + PROCESS_STATE_IDLE = 8; + PROCESS_STATE_UNDEFINED = 9; +} + +/** + * Process information + * @return pid ( process identifier ), process name, the state, todo :) + * */ +message Process { + int32 pid = 1; + string name = 2; + ProcessState state = 3; + uint64 utime = 4; + uint32 num_threads = 5; +} + +message ProcessActionRequest { + int32 pid = 1; + int32 signal = 2; +} + +message ProcessActionReply { + + bool succes = 1; +} diff --git a/proto/services/v3/services.proto b/proto/services/v3/services.proto new file mode 100644 index 0000000..01e9755 --- /dev/null +++ b/proto/services/v3/services.proto @@ -0,0 +1,124 @@ +syntax = "proto3"; + +package services.v3; + +option go_package = "paradigm-ehb/agent/proto/services/v3"; + + +service HandlerService { + rpc PerformUnitAction (UnitActionRequest) returns (UnitActionReply); + rpc PerformUnitFileAction (UnitFileActionRequest) returns (UnitFileActionReply); + rpc GetAllUnits (GetUnitsRequest) returns (GetUnitsReply); + rpc GetLoadedUnits (GetUnitsRequest) returns (GetUnitsReply); + rpc GetFilteredUnits (GetUnitsFilteredRequest) returns (GetUnitsReply); + rpc GetUnitStatus (GetUnitStatusRequest) returns (GetUnitStatusReply); +} + + +message UnitActionRequest { + string unit_name = 1; + + enum UnitAction { + UNIT_ACTION_UNSPECIFIED = 0; + UNIT_ACTION_START = 1; + UNIT_ACTION_STOP = 2; + UNIT_ACTION_RESTART = 3; + } + + UnitAction action = 2; + bool force = 3; +} + +message UnitActionReply { + bytes status = 1; + bool success = 2; + string error_message = 3; +} + + +message UnitFileActionRequest { + string unit_name = 1; + + enum UnitFileAction { + UNIT_FILE_ACTION_UNSPECIFIED = 0; + UNIT_FILE_ACTION_ENABLE = 1; + UNIT_FILE_ACTION_DISABLE = 2; + } + + UnitFileAction action = 2; + bool runtime = 3; + bool force = 4; +} + +message UnitFileActionReply { + bytes status = 1; + bool success = 2; + string error_message = 3; +} + + +message LoadedUnit { + string name = 1; + string description = 2; + string load_state = 3; + string sub_state = 4; + string active_state = 5; + string dep_unit = 6; + + string object_path = 7; + + uint32 queued_job = 8; + string job_type = 9; + string job_path = 10; +} + +message Unit { + string name = 1; + string state = 2; +} + + +message GetUnitsRequest { + + enum UnitState { + UNIT_STATE_UNSPECIFIED = 0; + ENABLED = 1; + DISABLED = 2; + } + + string name = 1; + UnitState state = 2; +} + +message GetUnitsFilteredRequest { + + enum UnitFileState { + + UNIT_FILE_STATE_UNSPECIFIED = 0; + LOADED = 1; + NOT_FOUND = 2; + BAD_SETTING = 3; + ERROR = 4; + MASKED = 5; + } + + repeated UnitFileState filters = 1; + +} + +message GetUnitsReply { + repeated LoadedUnit units = 1; + bool success = 2; + string error_message = 3; +} + + +message GetUnitStatusRequest { + string unit_name = 1; +} + +message GetUnitStatusReply { + string state = 1; + bool success = 2; + string error_message = 3; +} diff --git a/test/grpc_resources_test.sh b/test/grpc_resources_test.sh new file mode 100755 index 0000000..4e7b93b --- /dev/null +++ b/test/grpc_resources_test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh +set -eu + +ADDR="${ADDR:-localhost:5000}" + +echo "== ResourcesService integration test ==" +echo "Target: $ADDR" +echo + +command -v grpcurl >/dev/null 2>&1 || { + echo "ERROR: grpcurl not found" + exit 1 +} + +echo "== Checking service availability ==" +grpcurl -plaintext "$ADDR" list resources.v2.ResourcesService >/dev/null +echo "OK" +echo + +echo "== Calling GetSystemResources ==" +grpcurl -plaintext \ + -format text \ + "$ADDR" \ + resources.v2.ResourcesService/GetSystemResources +echo + +echo "== Test completed ==" diff --git a/test/grpc_resources_v2_test.sh b/test/grpc_resources_v2_test.sh new file mode 100755 index 0000000..b372ae6 --- /dev/null +++ b/test/grpc_resources_v2_test.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +# gRPCurl test script for ResourcesServiceV2 +# Make sure your gRPC server is running before executing these commands + +# Color codes for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Default server address +SERVER="localhost:5000" + +# Check if custom server address provided +if [ ! -z "$1" ]; then + SERVER="$1" +fi + +echo -e "${BLUE}Testing ResourcesServiceV2 on ${SERVER}${NC}\n" + +# List available services +echo -e "${GREEN}=== Listing available services ===${NC}" +grpcurl -plaintext ${SERVER} list +echo "" + +# List methods for ResourcesServiceV2 +echo -e "${GREEN}=== Listing methods for resources.v2.ResourcesService ===${NC}" +grpcurl -plaintext ${SERVER} list resources.v2.ResourcesService +echo "" + +# Describe the service +echo -e "${GREEN}=== Describing ResourcesService ===${NC}" +grpcurl -plaintext ${SERVER} describe resources.v2.ResourcesService +echo "" + +# Test GetSystemResources +echo -e "${GREEN}=== Getting System Resources ===${NC}" +grpcurl -plaintext \ + -d '{}' \ + ${SERVER} \ + resources.v2.ResourcesService/GetSystemResources +echo "" + +# Test KillProcess (example with PID 12345) +# WARNING: Uncomment and modify PID carefully - this will kill a process! +# echo -e "${GREEN}=== Killing Process (PID: 12345) ===${NC}" +# grpcurl -plaintext \ +# -d '{"pid": 12345}' \ +# ${SERVER} \ +# resources.v2.ResourcesService/KillProcess +# echo "" + +# Get system resources with formatted output (using jq if available) +echo -e "${GREEN}=== Getting System Resources (formatted) ===${NC}" +if command -v jq &> /dev/null; then + grpcurl -plaintext \ + -d '{}' \ + ${SERVER} \ + resources.v2.ResourcesService/GetSystemResources | jq '.' +else + echo "jq not installed - skipping formatted output" + echo "Install jq for pretty JSON: sudo apt-get install jq" +fi +echo "" + +# Show CPU information only +echo -e "${GREEN}=== CPU Information ===${NC}" +if command -v jq &> /dev/null; then + grpcurl -plaintext \ + -d '{}' \ + ${SERVER} \ + resources.v2.ResourcesService/GetSystemResources | jq '.resources.cpu' +else + echo "jq not installed - install it to filter output" +fi +echo "" + +# Show Memory information only +echo -e "${GREEN}=== Memory Information ===${NC}" +if command -v jq &> /dev/null; then + grpcurl -plaintext \ + -d '{}' \ + ${SERVER} \ + resources.v2.ResourcesService/GetSystemResources | jq '.resources.memory' +else + echo "jq not installed - install it to filter output" +fi +echo "" + +# Show first 5 processes +echo -e "${GREEN}=== First 5 Processes ===${NC}" +if command -v jq &> /dev/null; then + grpcurl -plaintext \ + -d '{}' \ + ${SERVER} \ + resources.v2.ResourcesService/GetSystemResources | jq '.resources.processes[:5]' +else + echo "jq not installed - install it to filter output" +fi +echo "" + +# Count total processes +echo -e "${GREEN}=== Total Process Count ===${NC}" +if command -v jq &> /dev/null; then + grpcurl -plaintext \ + -d '{}' \ + ${SERVER} \ + resources.v2.ResourcesService/GetSystemResources | jq '.resources.processes | length' +else + echo "jq not installed - install it to filter output" +fi +echo "" + +echo -e "${BLUE}Tests completed!${NC}" diff --git a/test/grpc_services_test.sh b/test/grpc_services_test.sh new file mode 100755 index 0000000..c2c13d3 --- /dev/null +++ b/test/grpc_services_test.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env sh +set -euo pipefail + +HOST="${HOST:-localhost}" +PORT="${PORT:-5000}" +ADDR="${HOST}:${PORT}" +SERVICE="services.v2.HandlerService" + +echo "== gRPC integration test ==" +echo "Target: ${ADDR}" +echo + +command -v grpcurl >/dev/null 2>&1 || { + echo "ERROR: grpcurl not found" + exit 1 +} + +echo "== Checking server availability ==" +grpcurl -plaintext "${ADDR}" list >/dev/null +echo "OK" +echo + +echo "== Listing all services ==" +grpcurl -plaintext "${ADDR}" list +echo + +echo "== Checking ${SERVICE} existence ==" +grpcurl -plaintext "${ADDR}" list "${SERVICE}" >/dev/null +echo "OK" +echo + + +echo "== Listing ${SERVICE} methods ==" +grpcurl -plaintext "${ADDR}" list "${SERVICE}" +echo + +echo "== GetAllUnits ==" +grpcurl -plaintext \ + -d '{}' \ + "${ADDR}" \ + "${SERVICE}/GetAllUnits" +echo + +echo "== GetLoadedUnits ==" +grpcurl -plaintext \ + -d '{}' \ + "${ADDR}" \ + "${SERVICE}/GetLoadedUnits" +echo + + echo "== GetUnitStatus (tailscaled.service) ==" + grpcurl -plaintext \ + -d '{ + "unitName": "tailscaled.service" + }' \ + "${ADDR}" \ + "${SERVICE}/GetUnitStatus" + echo + + echo "== PerformUnitAction: START tailscaled.service ==" + grpcurl -plaintext \ + -d '{ + "unitName": "tailscaled.service", + "action": "UNIT_ACTION_START" + }' \ + "${ADDR}" \ + "${SERVICE}/PerformUnitAction" + echo + + echo "== PerformUnitFileAction: ENABLE tailscaled.service (runtime=true, force=true) ==" + grpcurl -plaintext \ + -d '{ + "unitName": "tailscaled.service", + "action": "UNIT_FILE_ACTION_ENABLE", + "runtime": true, + "force": true + }' \ + "${ADDR}" \ + "${SERVICE}/PerformUnitFileAction" + echo + + echo "== PerformUnitAction: STOP tailscaled.service ==" + grpcurl -plaintext \ + -d '{ + "unitName": "tailscaled.service", + "action": "UNIT_ACTION_STOP", + "force": true + }' \ + "${ADDR}" \ + "${SERVICE}/PerformUnitAction" + echo + + + echo "== PerformUnitFileAction: DISABLE tailscaled.service ==" + grpcurl -plaintext \ + -d '{ + "unitName": "tailscaled.service", + "action": "UNIT_FILE_ACTION_DISABLE", + "runtime": true, + "force": true + }' \ + "${ADDR}" \ + "${SERVICE}/PerformUnitFileAction" + echo + +echo "== All gRPC v2 tests passed ==" diff --git a/test/grpc_test_script.sh b/test/grpc_services_v3_test.sh similarity index 85% rename from test/grpc_test_script.sh rename to test/grpc_services_v3_test.sh index 07f19b8..eab5361 100755 --- a/test/grpc_test_script.sh +++ b/test/grpc_services_v3_test.sh @@ -4,10 +4,9 @@ set -euo pipefail HOST="${HOST:-localhost}" PORT="${PORT:-5000}" ADDR="${HOST}:${PORT}" +SERVICE="services.v3.HandlerService" -SERVICE="services.v2.HandlerService" - -echo "== gRPC integration test ==" +echo "== gRPC v3 integration test ==" echo "Target: ${ADDR}" echo @@ -48,6 +47,17 @@ grpcurl -plaintext \ "${SERVICE}/GetLoadedUnits" echo +echo "== GetFilteredUnits (loaded, masked) ==" + +grpcurl -plaintext \ + -d '{ + "filters": ["LOADED", "MASKED"] + }' \ + localhost:5000 \ + services.v3.HandlerService/GetFilteredUnits + +echo + echo "== GetUnitStatus (tailscaled.service) ==" grpcurl -plaintext \ -d '{ @@ -83,14 +93,12 @@ echo "== PerformUnitAction: STOP tailscaled.service ==" grpcurl -plaintext \ -d '{ "unitName": "tailscaled.service", - "action": "UNIT_ACTION_STOP", - "force": true + "action": "UNIT_ACTION_STOP" }' \ "${ADDR}" \ "${SERVICE}/PerformUnitAction" echo - echo "== PerformUnitFileAction: DISABLE tailscaled.service ==" grpcurl -plaintext \ -d '{ @@ -103,4 +111,4 @@ grpcurl -plaintext \ "${SERVICE}/PerformUnitFileAction" echo -echo "== All gRPC v2 tests passed ==" +echo "== All gRPC v3 tests passed ==" diff --git a/test/setup_test.go b/test/integration_setup_test.go similarity index 62% rename from test/setup_test.go rename to test/integration_setup_test.go index e14c012..2b90e4d 100644 --- a/test/setup_test.go +++ b/test/integration_setup_test.go @@ -1,17 +1,23 @@ package server_test +/** + +TODO(nasr): update tests for the new grpc handler services version +*/ import ( "context" "net" "google.golang.org/grpc" "google.golang.org/grpc/test/bufconn" - "paradigm-ehb/agent/pkg/grpc_handler" - respb "paradigm-ehb/agent/gen/resources/v1" + "paradigm-ehb/agent/pkg/grpchandler" + servicesHandlerV1 "paradigm-ehb/agent/pkg/grpchandler/services/v1" + servicesHandlerV2 "paradigm-ehb/agent/pkg/grpchandler/services/v2" + // respb "paradigm-ehb/agent/gen/resources/v1" + serpb_v1 "paradigm-ehb/agent/gen/services/v1" serpb_v2 "paradigm-ehb/agent/gen/services/v2" - greetpb "paradigm-ehb/agent/gen/greet" journalpb "paradigm-ehb/agent/gen/journal/v1" @@ -37,9 +43,9 @@ func init() { grpc_health_v1.RegisterHealthServer(server, healthServer) - respb.RegisterResourcesServiceServer(server, &grpc_handler.ResourcesService{}) - serpb_v1.RegisterHandlerServiceServer(server, &grpc_handler.HandlerService{}) - serpb_v2.RegisterHandlerServiceServer(server, &grpc_handler.HandlerServiceV2{}) + // respb.RegisterResourcesServiceServer(server, &grpc_handler.ResourcesService{}) + serpb_v1.RegisterHandlerServiceServer(server, &servicesHandlerV1.HandlerService{}) + serpb_v2.RegisterHandlerServiceServer(server, &servicesHandlerV2.HandlerServiceV2{}) greetpb.RegisterGreeterServer(server, &grpc_handler.GreeterServer{}) journalpb.RegisterJournalServiceServer(server, &grpc_handler.JournalService{}) diff --git a/test/action_test.go b/test/journal_action_test.go similarity index 99% rename from test/action_test.go rename to test/journal_action_test.go index 635c423..302b846 100644 --- a/test/action_test.go +++ b/test/journal_action_test.go @@ -1,5 +1,6 @@ package server_test +/* import ( "context" "testing" @@ -13,9 +14,8 @@ import ( "google.golang.org/grpc/credentials/insecure" ) -/** TODO(nasr): write test for reboot and shutdown, what would be a proper way of doing this -*/ + func TestActions_all(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() @@ -45,3 +45,4 @@ func TestActions_all(t *testing.T) { t.Fatalf("unexpected response: %q", resp) } } +*/ diff --git a/test/journal_service_test.go b/test/journal_service_test.go new file mode 100644 index 0000000..3dcd0c8 --- /dev/null +++ b/test/journal_service_test.go @@ -0,0 +1,44 @@ +package server_test +// +// import ( +// "context" +// "testing" +// "time" +// +// pb "paradigm-ehb/agent/gen/journal/v1" +// +// "google.golang.org/grpc/resolver" +// +// "google.golang.org/grpc" +// "google.golang.org/grpc/credentials/insecure" +// ) +// +// +// func TestJournal_all(t *testing.T) { +// ctx, cancel := context.WithTimeout(context.Background(), time.Second) +// defer cancel() +// +// resolver.SetDefaultScheme("passthrough") +// +// clientConn, err := grpc.NewClient( +// "bufnet", +// grpc.WithContextDialer(BufDialer), +// grpc.WithTransportCredentials(insecure.NewCredentials()), +// ) +// +// if err != nil { +// t.Fatalf("failed to create client: %v", err) +// } +// defer clientConn.Close() +// +// client := pb.NewJournalServiceClient(clientConn) +// +// resp, err := client.Action(ctx, &pb.JournalRequest{}) +// if err != nil { +// t.Fatalf("rpc failed: %v", err) +// } +// +// if resp == nil { +// t.Fatalf("unexpected response: %q", resp) +// } +// } diff --git a/test/journal_test.go b/test/journal_test.go deleted file mode 100644 index 5db71ba..0000000 --- a/test/journal_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package server_test - -import ( - "context" - "testing" - "time" - - pb "paradigm-ehb/agent/gen/journal/v1" - - "google.golang.org/grpc/resolver" - - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" -) - - -func TestJournal_all(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - resolver.SetDefaultScheme("passthrough") - - clientConn, err := grpc.NewClient( - "bufnet", - grpc.WithContextDialer(BufDialer), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) - - if err != nil { - t.Fatalf("failed to create client: %v", err) - } - defer clientConn.Close() - - client := pb.NewJournalServiceClient(clientConn) - - resp, err := client.Action(ctx, &pb.JournalRequest{}) - if err != nil { - t.Fatalf("rpc failed: %v", err) - } - - if resp == nil { - t.Fatalf("unexpected response: %q", resp) - } -} diff --git a/test/process_action.sh b/test/process_action.sh new file mode 100755 index 0000000..569d500 --- /dev/null +++ b/test/process_action.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -eu + +grpcurl -plaintext \ + -d '{ + "pid": 2497, + "signal": 15 + }' \ + localhost:5000 \ + resources.v2.ResourcesService/ProcessAction diff --git a/test/resources_service_test.go b/test/resources_service_test.go new file mode 100644 index 0000000..6bf5cf3 --- /dev/null +++ b/test/resources_service_test.go @@ -0,0 +1,44 @@ +package server_test +// +// import ( +// "context" +// "testing" +// "time" +// +// pb "paradigm-ehb/agent/gen/resources/v1" +// +// "google.golang.org/grpc/resolver" +// +// "google.golang.org/grpc" +// "google.golang.org/grpc/credentials/insecure" +// ) +// +// +// func TestResources_All(t *testing.T) { +// ctx, cancel := context.WithTimeout(context.Background(), time.Second) +// defer cancel() +// +// resolver.SetDefaultScheme("passthrough") +// +// clientConn, err := grpc.NewClient( +// "bufnet", +// grpc.WithContextDialer(BufDialer), +// grpc.WithTransportCredentials(insecure.NewCredentials()), +// ) +// +// if err != nil { +// t.Fatalf("failed to create client: %v", err) +// } +// defer clientConn.Close() +// +// client := pb.NewResourcesServiceClient(clientConn) +// +// resp, err := client.GetSystemResources(ctx, &pb.GetSystemResourcesRequest{}) +// if err != nil { +// t.Fatalf("rpc failed: %v", err) +// } +// +// if resp == nil { +// t.Fatalf("unexpected response: %q", resp) +// } +// } diff --git a/test/resources_test.go b/test/resources_test.go deleted file mode 100644 index 9fc92fe..0000000 --- a/test/resources_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package server_test - -import ( - "context" - "testing" - "time" - - pb "paradigm-ehb/agent/gen/resources/v1" - - "google.golang.org/grpc/resolver" - - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" -) - - -func TestResources_All(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - resolver.SetDefaultScheme("passthrough") - - clientConn, err := grpc.NewClient( - "bufnet", - grpc.WithContextDialer(BufDialer), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) - - if err != nil { - t.Fatalf("failed to create client: %v", err) - } - defer clientConn.Close() - - client := pb.NewResourcesServiceClient(clientConn) - - resp, err := client.GetSystemResources(ctx, &pb.GetSystemResourcesRequest{}) - if err != nil { - t.Fatalf("rpc failed: %v", err) - } - - if resp == nil { - t.Fatalf("unexpected response: %q", resp) - } -} diff --git a/test/run_all_tests.sh b/test/run_all_tests.sh new file mode 100755 index 0000000..d35c7db --- /dev/null +++ b/test/run_all_tests.sh @@ -0,0 +1,253 @@ +#!/bin/bash +# gRPCurl test commands for all services +# Make sure your gRPC server is running on localhost:5000 (adjust port as needed) +# Install grpcurl: go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest + +SERVER="localhost:5000" + +echo "======================================" +echo "ResourcesServiceV2 Tests" +echo "======================================" + +# Get System Resources +echo -e "\n[1] GetSystemResources" +grpcurl -plaintext \ + -d '{}' \ + $SERVER \ + resources.v2.ResourcesService/GetSystemResources + +# Kill Process (replace with actual PID) +echo -e "\n[2] ProcessAction (SIGTERM - signal 15)" +grpcurl -plaintext \ + -d '{ + "pid": 12345, + "signal": 15 + }' \ + $SERVER \ + resources.v2.ResourcesService/ProcessAction + +echo -e "\n[3] ProcessAction (SIGKILL - signal 9)" +grpcurl -plaintext \ + -d '{ + "pid": 12345, + "signal": 9 + }' \ + $SERVER \ + resources.v2.ResourcesService/ProcessAction + +echo "======================================" +echo "HandlerServicev3 Tests (Systemd)" +echo "======================================" + +# Get All Units +echo -e "\n[4] GetAllUnits" +grpcurl -plaintext \ + -d '{}' \ + $SERVER \ + services.v3.HandlerService/GetAllUnits + +# Get Loaded Units +echo -e "\n[5] GetLoadedUnits" +grpcurl -plaintext \ + -d '{}' \ + $SERVER \ + services.v3.HandlerService/GetLoadedUnits + +# Get Filtered Units +echo -e "\n[6] GetFilteredUnits - LOADED" +grpcurl -plaintext \ + -d '{ + "filters": ["LOADED"] + }' \ + $SERVER \ + services.v3.HandlerService/GetFilteredUnits + +echo -e "\n[7] GetFilteredUnits - ERROR and MASKED" +grpcurl -plaintext \ + -d '{ + "filters": ["ERROR", "MASKED"] + }' \ + $SERVER \ + services.v3.HandlerService/GetFilteredUnits + +# Get Unit Status +echo -e "\n[8] GetUnitStatus - nginx.service" +grpcurl -plaintext \ + -d '{ + "unit_name": "nginx.service" + }' \ + $SERVER \ + services.v3.HandlerService/GetUnitStatus + +echo -e "\n[9] GetUnitStatus - sshd.service" +grpcurl -plaintext \ + -d '{ + "unit_name": "sshd.service" + }' \ + $SERVER \ + services.v3.HandlerService/GetUnitStatus + +# Perform Unit Action - Start +echo -e "\n[10] PerformUnitAction - START nginx.service" +grpcurl -plaintext \ + -d '{ + "unit_name": "nginx.service", + "action": "UNIT_ACTION_START" + }' \ + $SERVER \ + services.v3.HandlerService/PerformUnitAction + +# Perform Unit Action - Stop +echo -e "\n[11] PerformUnitAction - STOP nginx.service" +grpcurl -plaintext \ + -d '{ + "unit_name": "nginx.service", + "action": "UNIT_ACTION_STOP" + }' \ + $SERVER \ + services.v3.HandlerService/PerformUnitAction + +# Perform Unit Action - Restart +echo -e "\n[12] PerformUnitAction - RESTART nginx.service" +grpcurl -plaintext \ + -d '{ + "unit_name": "nginx.service", + "action": "UNIT_ACTION_RESTART" + }' \ + $SERVER \ + services.v3.HandlerService/PerformUnitAction + +# Perform Unit File Action - Enable +echo -e "\n[13] PerformUnitFileAction - ENABLE nginx.service" +grpcurl -plaintext \ + -d '{ + "unit_name": "nginx.service", + "action": "UNIT_FILE_ACTION_ENABLE", + "runtime": false, + "force": false + }' \ + $SERVER \ + services.v3.HandlerService/PerformUnitFileAction + +# Perform Unit File Action - Disable +echo -e "\n[14] PerformUnitFileAction - DISABLE nginx.service" +grpcurl -plaintext \ + -d '{ + "unit_name": "nginx.service", + "action": "UNIT_FILE_ACTION_DISABLE", + "runtime": false, + "force": true + }' \ + $SERVER \ + services.v3.HandlerService/PerformUnitFileAction + +echo "======================================" +echo "DeviceActionsService Tests" +echo "======================================" + +# Shutdown (WARNING: Will shutdown the system!) +echo -e "\n[15] Action - SHUTDOWN (commented out for safety)" +# grpcurl -plaintext \ +# -d '{ +# "device_action": "DEVICE_ACTION_SHUTDOWN" +# }' \ +# $SERVER \ +# actions.v1.ActionService/Action + +# Reboot (WARNING: Will reboot the system!) +echo -e "\n[16] Action - REBOOT (commented out for safety)" +# grpcurl -plaintext \ +# -d '{ +# "device_action": "DEVICE_ACTION_REBOOT" +# }' \ +# $SERVER \ +# actions.v1.ActionService/Action + +# Suspend +echo -e "\n[17] Action - SUSPEND" +grpcurl -plaintext \ + -d '{ + "device_action": "DEVICE_ACTION_SUSPEND" + }' \ + $SERVER \ + actions.v1.ActionService/Action + +# Hibernate +echo -e "\n[18] Action - HIBERNATE" +grpcurl -plaintext \ + -d '{ + "device_action": "DEVICE_ACTION_HIBERNATE" + }' \ + $SERVER \ + actions.v1.ActionService/Action + +echo "======================================" +echo "JournalService Tests (Server Streaming)" +echo "======================================" + +# Journal by Systemd Unit +echo -e "\n[19] Action - Get journal for nginx.service" +grpcurl -plaintext \ + -d '{ + "field": 0, + "value": "nginx.service", + "numFromTail": 100, + "cursor": "", + "path": "" + }' \ + $SERVER \ + journal.v1.JournalService/Action + +# Journal by PID +echo -e "\n[20] Action - Get journal for PID 1" +grpcurl -plaintext \ + -d '{ + "field": 1, + "value": "1", + "cursor": "", + "path": "" + }' \ + $SERVER \ + journal.v1.JournalService/Action + +# Journal by UID +echo -e "\n[21] Action - Get journal for UID 0 (root)" +grpcurl -plaintext \ + -d '{ + "field": 2, + "value": "0", + "numFromTail": 100, + "cursor": "", + "path": "" + }' \ + $SERVER \ + journal.v1.JournalService/Action + +# Journal by GID +echo -e "\n[22] Action - Get journal for GID 0 (root)" +grpcurl -plaintext \ + -d '{ + "field": 3, + "value": "0", + "numFromTail": 100, + "cursor": "", + "path": "" + }' \ + $SERVER \ + journal.v1.JournalService/Action + +echo -e "\n======================================" +echo "List Available Services" +echo "======================================" + +grpcurl -plaintext $SERVER list + +echo -e "\n======================================" +echo "Describe a Service" +echo "======================================" + +grpcurl -plaintext $SERVER describe resources.v2.ResourcesService + +echo -e "\n======================================" +echo "Tests Complete!" +echo "======================================" diff --git a/test/services_v1_test.go b/test/services_v1_integration_test.go similarity index 99% rename from test/services_v1_test.go rename to test/services_v1_integration_test.go index 69dc9c8..844c030 100644 --- a/test/services_v1_test.go +++ b/test/services_v1_integration_test.go @@ -1,5 +1,6 @@ package server_test +/* import ( "context" "testing" @@ -49,3 +50,4 @@ func TestService_Test(t *testing.T) { t.Fatalf("unexpected response: %q", resp) } } +*/ diff --git a/test/services_v2_test.go b/test/services_v2_integration_test.go similarity index 100% rename from test/services_v2_test.go rename to test/services_v2_integration_test.go