344 lines
9.7 KiB
Go
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)
|
|
}
|