From 81a94322f73011c69c5447b7fab593f760d730e0 Mon Sep 17 00:00:00 2001 From: mac Date: Thu, 23 Jul 2026 12:04:15 +0800 Subject: [PATCH] feat(delivery): add AWX API client Implement AWXClient with Launch, GetJob, and Cancel methods for orchestrating Ansible job templates via AWX REST API. Uses Bearer token auth with 30s timeout. Includes unit tests with mock transport. Relates-to: #97 --- server/internal/service/awx.go | 117 ++++++++++++++++++++++++++++ server/internal/service/awx_test.go | 39 ++++++++++ 2 files changed, 156 insertions(+) create mode 100644 server/internal/service/awx.go create mode 100644 server/internal/service/awx_test.go diff --git a/server/internal/service/awx.go b/server/internal/service/awx.go new file mode 100644 index 0000000..8b919ab --- /dev/null +++ b/server/internal/service/awx.go @@ -0,0 +1,117 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +type AWXClient struct { + baseURL string + token string + client *http.Client +} + +type AWXLaunchRequest struct { + InventoryID uint64 + Limit string + ExtraVars map[string]any +} + +type AWXJob struct { + ID uint64 `json:"id"` + Status string `json:"status"` + Failed bool `json:"failed"` +} + +func NewAWXClient(baseURL, token string) *AWXClient { + return &AWXClient{ + baseURL: strings.TrimRight(baseURL, "/"), + token: strings.TrimSpace(token), + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *AWXClient) Configured() bool { + return c.baseURL != "" && c.token != "" +} + +func (c *AWXClient) Launch(ctx context.Context, templateID uint64, input AWXLaunchRequest) (*AWXJob, error) { + body := map[string]any{"inventory": input.InventoryID, "extra_vars": input.ExtraVars} + if input.Limit != "" { + body["limit"] = input.Limit + } + var job AWXJob + if err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/job_templates/%d/launch/", templateID), body, &job); err != nil { + return nil, err + } + if job.ID == 0 { + return nil, fmt.Errorf("AWX launch response did not include a job id") + } + return &job, nil +} + +func (c *AWXClient) GetJob(ctx context.Context, jobID string) (*AWXJob, error) { + if _, err := strconv.ParseUint(jobID, 10, 64); err != nil { + return nil, fmt.Errorf("invalid AWX job id %q", jobID) + } + var job AWXJob + if err := c.request(ctx, http.MethodGet, "/api/v2/jobs/"+jobID+"/", nil, &job); err != nil { + return nil, err + } + return &job, nil +} + +func (c *AWXClient) Cancel(ctx context.Context, jobID string) error { + if _, err := strconv.ParseUint(jobID, 10, 64); err != nil { + return fmt.Errorf("invalid AWX job id %q", jobID) + } + return c.request(ctx, http.MethodPost, "/api/v2/jobs/"+jobID+"/cancel/", map[string]any{}, nil) +} + +func (c *AWXClient) request(ctx context.Context, method, path string, payload any, output any) error { + if !c.Configured() { + return fmt.Errorf("AWX_BASE_URL and AWX_TOKEN must be configured") + } + var body io.Reader + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("AWX request: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("AWX returned %s: %s", resp.Status, strings.TrimSpace(string(raw))) + } + if output != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, output); err != nil { + return fmt.Errorf("decode AWX response: %w", err) + } + } + return nil +} diff --git a/server/internal/service/awx_test.go b/server/internal/service/awx_test.go new file mode 100644 index 0000000..e074231 --- /dev/null +++ b/server/internal/service/awx_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestAWXClientLaunchAndGetJob(t *testing.T) { + client := NewAWXClient("https://awx.example", "token") + client.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Header.Get("Authorization") != "Bearer token" { + t.Fatalf("missing auth header") + } + job := AWXJob{ID: 42, Status: "pending"} + if r.URL.Path == "/api/v2/jobs/42/" { + job.Status = "successful" + } else if r.URL.Path != "/api/v2/job_templates/7/launch/" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + raw, _ := json.Marshal(job) + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(bytes.NewReader(raw)), Header: make(http.Header)}, nil + }) + job, err := client.Launch(context.Background(), 7, AWXLaunchRequest{InventoryID: 3, ExtraVars: map[string]any{"task_id": "t"}}) + if err != nil || job.ID != 42 { + t.Fatalf("launch = %#v, err=%v", job, err) + } + job, err = client.GetJob(context.Background(), "42") + if err != nil || job.Status != "successful" { + t.Fatalf("get job = %#v, err=%v", job, err) + } +}