Files
homelab/knecht/portainer/client.go
2026-04-04 14:34:22 +02:00

200 lines
4.4 KiB
Go

package portainer
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type Client struct {
baseURL string
token string
httpClient *http.Client
endpointID int
}
type Endpoint struct {
ID int `json:"Id"`
Name string `json:"Name"`
}
type EnvVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
type Stack struct {
ID int `json:"Id"`
Name string `json:"Name"`
Status int `json:"Status"` // 1 = active, 2 = inactive
EndpointID int `json:"EndpointId"`
Env []EnvVar `json:"Env"`
}
type StackFile struct {
StackFileContent string `json:"StackFileContent"`
}
func New(baseURL, token, endpointName string) (*Client, error) {
c := &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{},
}
id, err := c.discoverEndpoint(endpointName)
if err != nil {
return nil, err
}
c.endpointID = id
return c, nil
}
func (c *Client) EndpointID() int {
return c.endpointID
}
func (c *Client) discoverEndpoint(name string) (int, error) {
var endpoints []Endpoint
if err := c.get("/api/endpoints", &endpoints); err != nil {
return 0, fmt.Errorf("listing endpoints: %w", err)
}
if len(endpoints) == 0 {
return 0, fmt.Errorf("no endpoints found in Portainer")
}
// If a name is specified, match by name; otherwise use the first active one.
for _, e := range endpoints {
if name == "" || e.Name == name {
return e.ID, nil
}
}
return 0, fmt.Errorf("endpoint %q not found", name)
}
func (c *Client) ListStacks() ([]Stack, error) {
var stacks []Stack
if err := c.get("/api/stacks", &stacks); err != nil {
return nil, err
}
return stacks, nil
}
func (c *Client) GetStack(id int) (*Stack, error) {
var s Stack
if err := c.get(fmt.Sprintf("/api/stacks/%d", id), &s); err != nil {
return nil, err
}
return &s, nil
}
func (c *Client) GetStackByName(name string) (*Stack, error) {
stacks, err := c.ListStacks()
if err != nil {
return nil, err
}
for _, s := range stacks {
if s.Name == name {
return &s, nil
}
}
return nil, nil // not found, not an error
}
func (c *Client) GetStackFile(id int) (string, error) {
var f StackFile
if err := c.get(fmt.Sprintf("/api/stacks/%d/file", id), &f); err != nil {
return "", err
}
return f.StackFileContent, nil
}
func (c *Client) CreateStack(name, composeContent string, env []EnvVar) (*Stack, error) {
body := map[string]any{
"name": name,
"stackFileContent": composeContent,
"env": env,
}
var s Stack
if err := c.post(fmt.Sprintf("/api/stacks/create/standalone/string?endpointId=%d", c.endpointID), body, &s); err != nil {
return nil, err
}
return &s, nil
}
func (c *Client) UpdateStack(id int, composeContent string, env []EnvVar) (*Stack, error) {
body := map[string]any{
"stackFileContent": composeContent,
"env": env,
"prune": true,
"pullImage": false,
}
var s Stack
if err := c.put(fmt.Sprintf("/api/stacks/%d?endpointId=%d", id, c.endpointID), body, &s); err != nil {
return nil, err
}
return &s, nil
}
// request helpers
func (c *Client) get(path string, out any) error {
resp, err := c.do("GET", path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(out)
}
func (c *Client) post(path string, body, out any) error {
resp, err := c.do("POST", path, body)
if err != nil {
return err
}
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(out)
}
func (c *Client) put(path string, body, out any) error {
resp, err := c.do("PUT", path, body)
if err != nil {
return err
}
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(out)
}
func (c *Client) do(method, path string, body any) (*http.Response, error) {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.baseURL+path, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("X-API-Key", c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("portainer API %s %s: %s — %s", method, path, resp.Status, string(b))
}
return resp, nil
}