Files
xinfra/server/internal/service/awx.go
T
mac 81a94322f7 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
2026-07-23 12:04:15 +08:00

118 lines
3.0 KiB
Go

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
}