Files
portable/main.go
2025-10-05 19:53:22 +01:00

344 lines
9.7 KiB
Go

package main
import (
"bytes"
"crypto/tls"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
"github.com/google/uuid"
)
// -------------------------------
// CLI configuration
// -------------------------------
type Config struct {
PortainerURL string
PortainerToken string
EndpointID int
StackName string
RepoURL string
RepoRef string
RepoComposeFile string
RepoUsername string
RepoPassword string
TLSSkipVerify bool
EnvData string
}
// -------------------------------
// Data structures
// -------------------------------
type Stacks []Stack
type Stack struct {
AdditionalFiles []string `json:"AdditionalFiles"`
AutoUpdate AutoUpdate `json:"AutoUpdate"`
EndpointID int64 `json:"EndpointId"`
EntryPoint string `json:"EntryPoint"`
Env []Env `json:"Env"`
ID int64 `json:"Id"`
Name string `json:"Name"`
Option Option `json:"Option"`
ResourceControl ResourceControl `json:"ResourceControl"`
Status int64 `json:"Status"`
SwarmID string `json:"SwarmId"`
Type int64 `json:"Type"`
CreatedBy string `json:"createdBy"`
CreationDate int64 `json:"creationDate"`
FromAppTemplate bool `json:"fromAppTemplate"`
GitConfig GitConfig `json:"gitConfig"`
Namespace string `json:"namespace"`
ProjectPath string `json:"projectPath"`
UpdateDate int64 `json:"updateDate"`
UpdatedBy string `json:"updatedBy"`
}
type AutoUpdate struct {
ForcePullImage bool `json:"forcePullImage"`
ForceUpdate bool `json:"forceUpdate"`
Interval string `json:"interval"`
JobID string `json:"jobID"`
Webhook string `json:"webhook"`
}
type Env struct {
Name string `json:"name"`
Value string `json:"value"`
}
type GitConfig struct {
Authentication Authentication `json:"authentication"`
ConfigFilePath string `json:"configFilePath"`
ConfigHash string `json:"configHash"`
ReferenceName string `json:"referenceName"`
TlsskipVerify bool `json:"tlsskipVerify"`
URL string `json:"url"`
}
type Authentication struct {
AuthorizationType int64 `json:"authorizationType"`
GitCredentialID int64 `json:"gitCredentialID"`
Password string `json:"password"`
Username string `json:"username"`
}
type Option struct {
Prune bool `json:"prune"`
}
type ResourceControl struct {
AccessLevel int64 `json:"AccessLevel"`
AdministratorsOnly bool `json:"AdministratorsOnly"`
ID int64 `json:"Id"`
OwnerID int64 `json:"OwnerId"`
Public bool `json:"Public"`
ResourceID string `json:"ResourceId"`
SubResourceIDS []string `json:"SubResourceIds"`
System bool `json:"System"`
TeamAccesses []TeamAccess `json:"TeamAccesses"`
Type int64 `json:"Type"`
UserAccesses []UserAccess `json:"UserAccesses"`
}
type TeamAccess struct {
AccessLevel int64 `json:"AccessLevel"`
TeamID int64 `json:"TeamId"`
}
type UserAccess struct {
AccessLevel int64 `json:"AccessLevel"`
UserID int64 `json:"UserId"`
}
// -------------------------------
// HTTP helpers
// -------------------------------
func request(client *http.Client, token, method, url string, body interface{}) (*http.Response, error) {
var buf io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
buf = bytes.NewBuffer(data)
}
req, err := http.NewRequest(method, url, buf)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("X-API-KEY", token)
if method != "GET" {
req.Header.Set("Content-Type", "application/json")
}
return client.Do(req)
}
// -------------------------------
// Stack deployment logic
// -------------------------------
func Deploy(cfg Config) error {
client := &http.Client{Timeout: 15 * time.Second}
if cfg.TLSSkipVerify {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
var envVars []Env
if cfg.EnvData != "" {
if err := json.Unmarshal([]byte(cfg.EnvData), &envVars); err != nil {
return fmt.Errorf("invalid env data: %w", err)
}
}
id := uuid.New()
payload := map[string]interface{}{
"method": "repository",
"type": "standalone",
"Name": cfg.StackName,
"RepositoryURL": cfg.RepoURL,
"RepositoryReferenceName": cfg.RepoRef,
"ComposeFile": cfg.RepoComposeFile,
"AdditionalFiles": []string{},
"RepositoryAuthentication": true,
"RepositoryUsername": cfg.RepoUsername,
"RepositoryPassword": cfg.RepoPassword,
"Env": envVars,
"TLSSkipVerify": cfg.TLSSkipVerify,
"AutoUpdate": map[string]interface{}{
"Interval": "",
"Webhook": id.String(),
"ForceUpdate": false,
"ForcePullImage": false,
},
}
url := fmt.Sprintf("%s/stacks/create/standalone/repository?endpointId=%d", cfg.PortainerURL, cfg.EndpointID)
resp, err := request(client, cfg.PortainerToken, "POST", url, payload)
if err != nil {
return fmt.Errorf("create stack: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("error creating stack: %s", string(body))
}
fmt.Printf("✅ Stack %s deployed successfully.\n", cfg.StackName)
return nil
}
func (s *Stack) Redeploy(cfg Config) error {
client := &http.Client{Timeout: 15 * time.Second}
if cfg.TLSSkipVerify {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
var envVars []Env
if cfg.EnvData != "" {
if err := json.Unmarshal([]byte(cfg.EnvData), &envVars); err != nil {
return fmt.Errorf("invalid env data: %w", err)
}
}
payload := map[string]interface{}{
"env": envVars,
"prune": false,
"PullImage": true,
"repositoryAuthentication": true,
"RepositoryReferenceName": "HEAD",
"repositoryPassword": cfg.RepoPassword,
"repositoryUsername": cfg.RepoUsername,
"stackName": cfg.StackName,
}
url := fmt.Sprintf("%s/stacks/%d/git/redeploy?endpointId=%d", cfg.PortainerURL, s.ID, cfg.EndpointID)
resp, err := request(client, cfg.PortainerToken, "PUT", url, payload)
if err != nil {
return fmt.Errorf("redeploy stack: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("error redeploying stack: %s", string(body))
}
fmt.Printf("♻️ Stack %s redeployed successfully.\n", cfg.StackName)
return nil
}
func findStack(cfg Config) (*Stack, error) {
client := &http.Client{Timeout: 15 * time.Second}
if cfg.TLSSkipVerify {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
resp, err := request(client, cfg.PortainerToken, "GET", fmt.Sprintf("%s/stacks", cfg.PortainerURL), nil)
if err != nil {
return nil, fmt.Errorf("fetch stacks: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed fetching stacks: %s", string(body))
}
var stacks Stacks
if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
return nil, fmt.Errorf("decode stacks: %w", err)
}
fmt.Printf("Stack data: %+v\n", stacks)
var existing *Stack = nil
for _, s := range stacks {
if s.GitConfig.URL == cfg.RepoURL && s.Name == cfg.StackName {
existing = &s
break
}
}
return existing, nil
}
// -------------------------------
// CLI setup
// -------------------------------
func main() {
cfg := Config{}
flag.StringVar(&cfg.PortainerURL, "url", "", "Portainer API base URL (e.g., https://portainer.example.com/api)")
flag.StringVar(&cfg.PortainerToken, "token", "", "Portainer API key or token")
endpointID := flag.String("endpoint", "", "Portainer endpoint ID")
flag.StringVar(&cfg.StackName, "stack", "", "Stack name")
flag.StringVar(&cfg.RepoURL, "repo-url", "", "Repository URL")
flag.StringVar(&cfg.RepoRef, "repo-ref", "main", "Git reference/branch")
flag.StringVar(&cfg.RepoComposeFile, "compose", "docker-compose.yml", "Path to Compose file in repo")
flag.StringVar(&cfg.RepoUsername, "repo-user", "", "Repository username (optional)")
flag.StringVar(&cfg.RepoPassword, "repo-pass", "", "Repository password (optional)")
flag.BoolVar(&cfg.TLSSkipVerify, "insecure", false, "Skip TLS certificate verification")
flag.StringVar(&cfg.EnvData, "env", "", "Environment variables in JSON format, e.g. '[{\"name\":\"VAR\",\"value\":\"123\"}]'")
flag.Parse()
if cfg.PortainerURL == "" || cfg.PortainerToken == "" ||
cfg.RepoURL == "" || cfg.RepoRef == "" || cfg.RepoComposeFile == "" || cfg.StackName == "" {
flag.Usage()
os.Exit(1)
}
id, err := strconv.Atoi(*endpointID)
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid endpoint ID: %v\n", err)
os.Exit(1)
}
cfg.EndpointID = id
stack, err := findStack(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to find stack: %v\n", err)
os.Exit(1)
}
if stack != nil {
fmt.Printf("✅ Found existing stack: %+v\n", stack)
if err := stack.Redeploy(cfg); err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to Redeploy: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
if err := Deploy(cfg); err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to deploy: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}