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) + } +}