-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add a command to stop the local stack #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,12 +8,14 @@ package agent | |||||||
| import ( | ||||||||
| "context" | ||||||||
| "encoding/json" | ||||||||
| "errors" | ||||||||
| "fmt" | ||||||||
| "io" | ||||||||
| "net/http" | ||||||||
| "net/url" | ||||||||
| "strconv" | ||||||||
| "strings" | ||||||||
| "syscall" | ||||||||
| "time" | ||||||||
| ) | ||||||||
|
|
||||||||
|
|
@@ -80,6 +82,11 @@ func (e *Unreachable) Error() string { | |||||||
|
|
||||||||
| func (e *Unreachable) Unwrap() error { return e.Cause } | ||||||||
|
|
||||||||
| func IsConnectionRefused(err error) bool { | ||||||||
| var unreachable *Unreachable | ||||||||
| return errors.As(err, &unreachable) && errors.Is(unreachable.Cause, syscall.ECONNREFUSED) | ||||||||
| } | ||||||||
|
|
||||||||
| // Client talks to one agent. | ||||||||
| type Client struct { | ||||||||
| baseURL string | ||||||||
|
|
@@ -169,3 +176,30 @@ func detail(body []byte) string { | |||||||
| } | ||||||||
| return strings.TrimSpace(string(body)) | ||||||||
| } | ||||||||
|
|
||||||||
| func (c *Client) Stop(ctx context.Context) error { | ||||||||
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/stop", nil) | ||||||||
| if err != nil { | ||||||||
| return err | ||||||||
| } | ||||||||
| req.Header.Set("X-Sourceant-Client", "cli") | ||||||||
| req.Header.Set("Accept", "application/json") | ||||||||
| resp, err := c.http.Do(req) | ||||||||
| if err != nil { | ||||||||
| return &Unreachable{BaseURL: c.baseURL, Cause: err} | ||||||||
| } | ||||||||
| defer func() { _ = resp.Body.Close() }() | ||||||||
| switch resp.StatusCode { | ||||||||
| case http.StatusNoContent: | ||||||||
| return nil | ||||||||
| case http.StatusOK, http.StatusNotFound, http.StatusMethodNotAllowed: | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment only explains the 200 case, but this branch also groups 404 and 405. Update it to describe all three unsupported statuses so future readers don't wonder why NotFound/MethodNotAllowed are handled here.
Suggested change
|
||||||||
| // Only 204 acknowledges shutdown; a generic 200 does not confirm it. | ||||||||
| return &Error{StatusCode: resp.StatusCode, Detail: "this agent does not support stop; update it with sourceant setup"} | ||||||||
| default: | ||||||||
| body, err := io.ReadAll(resp.Body) | ||||||||
| if err != nil { | ||||||||
| return err | ||||||||
| } | ||||||||
| return &Error{StatusCode: resp.StatusCode, Detail: detail(body)} | ||||||||
| } | ||||||||
| } | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package command | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/sourceant/cli/internal/agent" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| func stopCommand(opts *options) *cobra.Command { | ||
| return &cobra.Command{ | ||
| Use: "stop", | ||
| Short: "Stop the agent and its core", | ||
| Args: cobra.NoArgs, | ||
| RunE: func(cmd *cobra.Command, _ []string) error { | ||
| if err := opts.client().Stop(cmd.Context()); err != nil { | ||
| if agent.IsConnectionRefused(err) { | ||
| _, _ = fmt.Fprintln(cmd.OutOrStdout(), "SourceAnt is already stopped.") | ||
| return nil | ||
| } | ||
| return fmt.Errorf("could not stop SourceAnt: %w", err) | ||
| } | ||
| _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Stopped SourceAnt.") | ||
| return nil | ||
| }, | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,60 @@ | ||||||||||
| package command | ||||||||||
|
|
||||||||||
| import ( | ||||||||||
| "bytes" | ||||||||||
| "net/http" | ||||||||||
| "net/http/httptest" | ||||||||||
| "strings" | ||||||||||
| "testing" | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| func TestStopCommand(t *testing.T) { | ||||||||||
| for _, status := range []int{http.StatusNoContent, http.StatusOK, http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusInternalServerError} { | ||||||||||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||||||||
| if r.Method != "POST" || r.URL.Path != "/api/stop" || r.Header.Get("X-Sourceant-Client") != "cli" || r.Header.Get("Accept") != "application/json" { | ||||||||||
| t.Errorf("unexpected stop request: %s %s", r.Method, r.URL) | ||||||||||
| } | ||||||||||
| w.WriteHeader(status) | ||||||||||
| })) | ||||||||||
| var out, stderr bytes.Buffer | ||||||||||
| code := Run([]string{"--agent", server.URL, "stop"}, &out, &stderr) | ||||||||||
| server.Close() | ||||||||||
| if (code == 0) != (status == http.StatusNoContent) { | ||||||||||
|
nfebe marked this conversation as resolved.
|
||||||||||
| t.Fatalf("status=%d code=%d: %s", status, code, stderr.String()) | ||||||||||
| } | ||||||||||
| if status == http.StatusOK || status == http.StatusNotFound || status == http.StatusMethodNotAllowed { | ||||||||||
| if !strings.Contains(stderr.String(), "this agent does not support stop; update it with sourceant setup") { | ||||||||||
| t.Fatal(stderr.String()) | ||||||||||
| } | ||||||||||
| } | ||||||||||
| if code == 0 && !strings.Contains(out.String(), "Stopped SourceAnt.") { | ||||||||||
| t.Fatal(out.String()) | ||||||||||
| } | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func TestStopAlreadyStopped(t *testing.T) { | ||||||||||
| server := httptest.NewServer(http.NotFoundHandler()) | ||||||||||
| server.Close() | ||||||||||
| var out, stderr bytes.Buffer | ||||||||||
| if Run([]string{"--agent", server.URL, "stop"}, &out, &stderr) != 0 { | ||||||||||
| t.Fatal(stderr.String()) | ||||||||||
| } | ||||||||||
| if !strings.Contains(out.String(), "already stopped") { | ||||||||||
| t.Fatal(out.String()) | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func TestStopTimeoutIsNotAlreadyStopped(t *testing.T) { | ||||||||||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||||||||
| <-r.Context().Done() | ||||||||||
| })) | ||||||||||
| defer server.Close() | ||||||||||
| var out, stderr bytes.Buffer | ||||||||||
| if Run([]string{"--agent", server.URL, "--timeout", "20ms", "stop"}, &out, &stderr) == 0 { | ||||||||||
| t.Fatal("a timed-out stop request succeeded") | ||||||||||
| } | ||||||||||
| if strings.Contains(out.String(), "already stopped") || stderr.Len() == 0 { | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checking that stderr is non-empty is weak. Assert the actual error message so the test fails with a clear diagnostic if the wrong error path is taken (for example, if a future change prints "already stopped" to stderr instead of stdout).
Suggested change
|
||||||||||
| t.Fatalf("stdout=%s stderr=%s", out.String(), stderr.String()) | ||||||||||
| } | ||||||||||
| } | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exported functions should have a doc comment explaining their contract, especially since this is a new public helper used by the command package.