diff --git a/cmd/controller/controller.go b/cmd/controller/controller.go new file mode 100644 index 0000000..9375e3c --- /dev/null +++ b/cmd/controller/controller.go @@ -0,0 +1,15 @@ +package controller + +import ( + "github.com/spf13/cobra" +) + +var ControllerCmd = &cobra.Command{ + Use: "controller", + Short: "Manage morpher controller", + Long: `Manage morpher controller including ping, status, and info operations.`, +} + +func init() { + ControllerCmd.AddCommand(pingCmd, infoCmd) +} diff --git a/cmd/controller/info.go b/cmd/controller/info.go new file mode 100644 index 0000000..d81b4a6 --- /dev/null +++ b/cmd/controller/info.go @@ -0,0 +1,60 @@ +package controller + +import ( + "context" + "fmt" + + "morpherctl/internal/controller" + + "github.com/spf13/cobra" +) + +var infoCmd = &cobra.Command{ + Use: "info", + Short: "Get controller information", + Long: `Get detailed information about the morpher controller.`, + RunE: func(_ *cobra.Command, _ []string) error { + return getControllerInfo() + }, +} + +func getControllerInfo() error { + // Create controller client. + client, timeout, err := controller.CreateControllerClient() + if err != nil { + return fmt.Errorf("failed to create controller client: %w", err) + } + + fmt.Printf("Getting controller information: %s\n", client.GetBaseURL()) + + // Get controller info. + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + response, err := client.GetInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get controller info: %w", err) + } + + // Display response. + if response.Success { + fmt.Println("Successfully retrieved controller information") + + // Display detailed information if available. + if response.Result != nil { + fmt.Println("\nController Details:") + fmt.Printf(" OS: %s %s (%s)\n", + response.Result.OS.Name, + response.Result.OS.PlatformVersion, + response.Result.OS.KernelVersion) + fmt.Printf(" Go Version: %s\n", response.Result.GoVersion) + fmt.Printf(" Uptime: %s\n", response.Result.UpTime) + } else { + fmt.Println(" No detailed information available") + } + } else { + fmt.Printf("Failed to get controller information: %d\n", response.StatusCode) + } + + return nil +} diff --git a/cmd/controller/ping.go b/cmd/controller/ping.go new file mode 100644 index 0000000..08037fe --- /dev/null +++ b/cmd/controller/ping.go @@ -0,0 +1,50 @@ +package controller + +import ( + "context" + "fmt" + + "morpherctl/internal/controller" + + "github.com/spf13/cobra" +) + +var pingCmd = &cobra.Command{ + Use: "ping", + Short: "Ping the controller", + Long: `Send a ping request to the morpher controller to check connectivity.`, + RunE: func(_ *cobra.Command, _ []string) error { + return pingController() + }, +} + +func pingController() error { + // Create controller client. + client, timeout, err := controller.CreateControllerClient() + if err != nil { + return fmt.Errorf("failed to create controller client: %w", err) + } + + fmt.Printf("Sending ping request to controller: %s\n", client.GetBaseURL()) + + // Send ping request. + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + response, err := client.Ping(ctx) + if err != nil { + return fmt.Errorf("failed to connect to controller: %w", err) + } + + // Display response. + if response.Success { + fmt.Println("Controller responded successfully") + if response.ResponseTime != "" { + fmt.Printf("Response time: %v\n", response.ResponseTime) + } + } else { + fmt.Printf("Controller responded but with unexpected status code: %d\n", response.StatusCode) + } + + return nil +} diff --git a/cmd/root.go b/cmd/root.go index 703e2e9..3c7b941 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "morpherctl/cmd/config" + "morpherctl/cmd/controller" "morpherctl/cmd/version" ) @@ -29,4 +30,5 @@ func init() { rootCmd.AddCommand(version.VersionCmd) rootCmd.AddCommand(config.ConfigCmd) + rootCmd.AddCommand(controller.ControllerCmd) } diff --git a/internal/controller/client.go b/internal/controller/client.go new file mode 100644 index 0000000..27faaf3 --- /dev/null +++ b/internal/controller/client.go @@ -0,0 +1,193 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "morpherctl/internal/config" +) + +const ( + defaultControllerURL = "http://localhost:9000" + defaultTimeout = "30s" +) + +// Client handles communication with the morpher controller. +type Client struct { + baseURL string + timeout time.Duration + httpClient *http.Client + token string +} + +// PingResponse represents the response from a ping request. +type PingResponse struct { + StatusCode int `json:"status_code"` + ResponseTime string `json:"response_time,omitempty"` + Success bool `json:"success"` +} + +// OSInfo represents operating system information. +type OSInfo struct { + Name string `json:"Name"` + PlatformName string `json:"PlatformName"` + PlatformVersion string `json:"PlatformVersion"` + KernelVersion string `json:"KernelVersion"` +} + +// InfoResult represents the result data in info response. +type InfoResult struct { + OS OSInfo `json:"OS"` + GoVersion string `json:"GoVersion"` + UpTime string `json:"UpTime"` +} + +// InfoResponse represents the response from an info request. +type InfoResponse struct { + StatusCode int `json:"status_code"` + Success bool `json:"success"` + Result *InfoResult `json:"result,omitempty"` +} + +// NewClient creates a new controller client. +func NewClient(baseURL string, timeout time.Duration, token string) *Client { + if timeout == 0 { + timeout = 30 * time.Second + } + + return &Client{ + baseURL: baseURL, + timeout: timeout, + httpClient: &http.Client{ + Timeout: timeout, + }, + token: token, + } +} + +// GetControllerConfig retrieves common configuration values for controller commands. +func GetControllerConfig() (string, time.Duration, string, error) { + // Get configuration values. + configMgr := config.NewManager("") + + controllerURL, err := configMgr.GetString("controller.url") + if err != nil { + controllerURL = defaultControllerURL + } + + timeoutStr, err := configMgr.GetString("controller.timeout") + if err != nil { + timeoutStr = defaultTimeout + } + + timeout, err := time.ParseDuration(timeoutStr) + if err != nil { + timeout = 30 * time.Second + } + + token, err := configMgr.GetString("auth.token") + if err != nil { + token = "" + } + + return controllerURL, timeout, token, nil +} + +// CreateControllerClient creates a new controller client with configuration. +func CreateControllerClient() (*Client, time.Duration, error) { + controllerURL, timeout, token, err := GetControllerConfig() + if err != nil { + return nil, 0, fmt.Errorf("failed to get controller configuration: %w", err) + } + + client := NewClient(controllerURL, timeout, token) + return client, timeout, nil +} + +// newRequest creates a new HTTP request with context and authorization. +func (c *Client) newRequest(ctx context.Context, method, path string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + return req, nil +} + +// Ping sends a ping request to the controller. +func (c *Client) Ping(ctx context.Context) (*PingResponse, error) { + req, err := c.newRequest(ctx, "GET", "/ping") + if err != nil { + return nil, fmt.Errorf("failed to create ping request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send ping request: %w", err) + } + defer resp.Body.Close() + + response := &PingResponse{ + StatusCode: resp.StatusCode, + ResponseTime: resp.Header.Get("X-Response-Time"), + Success: resp.StatusCode == http.StatusOK, + } + + return response, nil +} + +// GetInfo retrieves detailed controller information. +func (c *Client) GetInfo(ctx context.Context) (*InfoResponse, error) { + req, err := c.newRequest(ctx, "GET", "/info") + if err != nil { + return nil, fmt.Errorf("failed to create info request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get controller info: %w", err) + } + defer resp.Body.Close() + + response := &InfoResponse{ + StatusCode: resp.StatusCode, + Success: resp.StatusCode == http.StatusOK, + } + + // If the request was successful, try to parse the response body. + if resp.StatusCode == http.StatusOK { + var result InfoResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return response, fmt.Errorf("failed to parse controller info response: %w", err) + } + response.Result = &result + } + + return response, nil +} + +// IsHealthy checks if the controller is healthy based on ping response. +func (c *Client) IsHealthy(ctx context.Context) (bool, error) { + response, err := c.Ping(ctx) + if err != nil { + return false, err + } + return response.Success, nil +} + +// GetBaseURL returns the base URL of the controller. +func (c *Client) GetBaseURL() string { + return c.baseURL +} + +// GetTimeout returns the timeout setting of the client. +func (c *Client) GetTimeout() time.Duration { + return c.timeout +} diff --git a/internal/controller/client_test.go b/internal/controller/client_test.go new file mode 100644 index 0000000..6df25e2 --- /dev/null +++ b/internal/controller/client_test.go @@ -0,0 +1,304 @@ +package controller + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewClient(t *testing.T) { + tests := []struct { + name string + baseURL string + timeout time.Duration + token string + expected *Client + }{ + { + name: "should create client with default timeout", + baseURL: "http://localhost:9000", + timeout: 0, + token: "test-token", + expected: &Client{ + baseURL: "http://localhost:9000", + timeout: 30 * time.Second, + token: "test-token", + }, + }, + { + name: "should create client with custom timeout", + baseURL: "http://localhost:9000", + timeout: 60 * time.Second, + token: "test-token", + expected: &Client{ + baseURL: "http://localhost:9000", + timeout: 60 * time.Second, + token: "test-token", + }, + }, + { + name: "should create client without token", + baseURL: "http://localhost:9000", + timeout: 30 * time.Second, + token: "", + expected: &Client{ + baseURL: "http://localhost:9000", + timeout: 30 * time.Second, + token: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(tt.baseURL, tt.timeout, tt.token) + + assert.Equal(t, tt.expected.baseURL, client.GetBaseURL()) + assert.Equal(t, tt.expected.timeout, client.GetTimeout()) + assert.Equal(t, tt.expected.token, client.token) + assert.NotNil(t, client.httpClient) + }) + } +} + +func TestClient_Ping(t *testing.T) { + tests := []struct { + name string + statusCode int + responseTime string + expectedSuccess bool + withToken bool + }{ + { + name: "should return success for 200 response", + statusCode: 200, + responseTime: "10ms", + expectedSuccess: true, + withToken: false, + }, + { + name: "should return failure for 500 response", + statusCode: 500, + responseTime: "", + expectedSuccess: false, + withToken: false, + }, + { + name: "should work with authorization token", + statusCode: 200, + responseTime: "15ms", + expectedSuccess: true, + withToken: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test server. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if authorization header is set when token is provided. + if tt.withToken { + authHeader := r.Header.Get("Authorization") + assert.Equal(t, "Bearer test-token", authHeader) + } + + // Set response time header if provided. + if tt.responseTime != "" { + w.Header().Set("X-Response-Time", tt.responseTime) + } + + w.WriteHeader(tt.statusCode) + _, err := w.Write([]byte("OK")) + require.NoError(t, err) + })) + defer server.Close() + + // Create client. + token := "" + if tt.withToken { + token = "test-token" + } + client := NewClient(server.URL, 30*time.Second, token) + + // Test ping. + ctx := context.Background() + response, err := client.Ping(ctx) + require.NoError(t, err) + + assert.Equal(t, tt.statusCode, response.StatusCode) + assert.Equal(t, tt.responseTime, response.ResponseTime) + assert.Equal(t, tt.expectedSuccess, response.Success) + }) + } +} + +// testHTTPResponse is a helper function to test HTTP responses. +func testHTTPResponse(t *testing.T, path string, statusCode int, expectedSuccess bool) { + // Create test server. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, path, r.URL.Path) + w.WriteHeader(statusCode) + + // Return appropriate response based on path. + switch path { + case "/info": + if statusCode == 200 { + // Return valid JSON for info endpoint. + jsonResponse := `{ + "OS": { + "Name": "darwin", + "PlatformName": "darwin", + "PlatformVersion": "15.3.1", + "KernelVersion": "24.3.0" + }, + "GoVersion": "go1.25.0", + "UpTime": "32s" + }` + _, err := w.Write([]byte(jsonResponse)) + require.NoError(t, err) + } else { + _, err := w.Write([]byte("Error")) + require.NoError(t, err) + } + default: + _, err := w.Write([]byte("OK")) + require.NoError(t, err) + } + })) + defer server.Close() + + // Create client. + client := NewClient(server.URL, 30*time.Second, "") + + // Test the request. + ctx := context.Background() + var response any + var err error + + switch path { + case "/info": + response, err = client.GetInfo(ctx) + default: + t.Fatalf("unknown path: %s", path) + } + + require.NoError(t, err) + + switch r := response.(type) { + case *InfoResponse: + assert.Equal(t, statusCode, r.StatusCode) + assert.Equal(t, expectedSuccess, r.Success) + default: + t.Fatalf("unexpected response type: %T", response) + } +} + +func TestClient_GetInfo(t *testing.T) { + tests := []struct { + name string + statusCode int + expectedSuccess bool + }{ + { + name: "should return success for 200 response", + statusCode: 200, + expectedSuccess: true, + }, + { + name: "should return failure for 500 response", + statusCode: 500, + expectedSuccess: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testHTTPResponse(t, "/info", tt.statusCode, tt.expectedSuccess) + }) + } +} + +func TestClient_IsHealthy(t *testing.T) { + tests := []struct { + name string + statusCode int + expectedHealthy bool + }{ + { + name: "should return healthy for 200 response", + statusCode: 200, + expectedHealthy: true, + }, + { + name: "should return unhealthy for 500 response", + statusCode: 500, + expectedHealthy: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test server. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.statusCode) + _, err := w.Write([]byte("OK")) + require.NoError(t, err) + })) + defer server.Close() + + // Create client. + client := NewClient(server.URL, 30*time.Second, "") + + // Test health check. + ctx := context.Background() + healthy, err := client.IsHealthy(ctx) + require.NoError(t, err) + + assert.Equal(t, tt.expectedHealthy, healthy) + }) + } +} + +func TestClient_AuthorizationHeader(t *testing.T) { + // Create test server that checks authorization header. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "Bearer test-token" { + w.WriteHeader(200) + _, err := w.Write([]byte("Authorized")) + require.NoError(t, err) + } else { + w.WriteHeader(401) + _, err := w.Write([]byte("Unauthorized")) + require.NoError(t, err) + } + })) + defer server.Close() + + t.Run("should include authorization header when token is provided", func(t *testing.T) { + client := NewClient(server.URL, 30*time.Second, "test-token") + + ctx := context.Background() + response, err := client.Ping(ctx) + require.NoError(t, err) + + assert.Equal(t, 200, response.StatusCode) + assert.True(t, response.Success) + }) + + t.Run("should not include authorization header when token is empty", func(t *testing.T) { + client := NewClient(server.URL, 30*time.Second, "") + + ctx := context.Background() + response, err := client.Ping(ctx) + require.NoError(t, err) + + assert.Equal(t, 401, response.StatusCode) + assert.False(t, response.Success) + }) +}