package main import ( "bytes" "crypto/tls" "encoding/base64" "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 EnvPayload struct { Name string `json:"name"` Value string `json:"value"` NeedsDeletion bool `json:"needsDeletion"` } 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: 120 * 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: 120 * time.Second} if cfg.TLSSkipVerify { client.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } var envVars []Env var envPayloads []EnvPayload if cfg.EnvData != "" { if err := json.Unmarshal([]byte(cfg.EnvData), &envVars); err != nil { return fmt.Errorf("invalid env data: %w", err) } } envPayloads = make([]EnvPayload, len(envVars)) for i := range envVars { envPayloads[i] = EnvPayload{ Name: envVars[i].Name, Value: envVars[i].Value, NeedsDeletion: false, } } payload := map[string]interface{}{ "env": envPayloads, "prune": false, "PullImage": true, "repositoryAuthentication": true, "RepositoryReferenceName": cfg.RepoRef, "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) } 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.StringVar(&cfg.EnvData, "env", "", "Environment variables in JSON format, e.g. '[{\"name\":\"VAR\",\"value\":\"123\"}]'") flag.BoolVar(&cfg.TLSSkipVerify, "insecure", false, "Skip TLS certificate verification") flag.Parse() if cfg.PortainerURL == "" || cfg.PortainerToken == "" || cfg.RepoURL == "" || cfg.RepoRef == "" || cfg.RepoComposeFile == "" || cfg.StackName == "" { flag.Usage() os.Exit(1) } decodedEnvData, err := base64.StdEncoding.DecodeString(cfg.EnvData) if err != nil { fmt.Fprintf(os.Stderr, "Invalid base64 env data: %v\n", err) os.Exit(1) } cfg.EnvData = string(decodedEnvData) 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) }