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
This commit is contained in:
mac
2026-07-23 12:04:15 +08:00
parent 76583c6766
commit 81a94322f7
2 changed files with 156 additions and 0 deletions
+117
View File
@@ -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
}
+39
View File
@@ -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)
}
}