chore: update main.go

This commit is contained in:
etwodev
2025-10-05 18:32:00 +01:00
parent 146caa853a
commit 493d862ad5
2 changed files with 297 additions and 154 deletions

View File

@@ -45,15 +45,26 @@ inputs:
runs: runs:
using: "docker" using: "docker"
image: "Dockerfile" image: "Dockerfile"
env: args:
PORTAINER_URL: ${{ inputs.portainer-url }} - "--url"
PORTAINER_TOKEN: ${{ inputs.portainer-token }} - "${{ inputs.portainer-url }}"
PORTAINER_ENDPOINT: ${{ inputs.portainer-endpoint }} - "--token"
STACK_NAME: ${{ inputs.stack-name }} - "${{ inputs.portainer-token }}"
REPO_URL: ${{ inputs.repo-url }} - "--endpoint"
REPO_REF: ${{ inputs.repo-ref }} - "${{ inputs.portainer-endpoint }}"
REPO_COMPOSE_FILE: ${{ inputs.repo-compose-file }} - "--stack"
REPO_USERNAME: ${{ inputs.repo-username }} - "${{ inputs.stack-name }}"
REPO_PASSWORD: ${{ inputs.repo-password }} - "--repo-url"
TLS_SKIP_VERIFY: ${{ inputs.tls-skip-verify }} - "${{ inputs.repo-url }}"
ENV_DATA: ${{ inputs.env-data }} - "--repo-ref"
- "${{ inputs.repo-ref }}"
- "--compose"
- "${{ inputs.repo-compose-file }}"
- "--repo-user"
- "${{ inputs.repo-username }}"
- "--repo-pass"
- "${{ inputs.repo-password }}"
- "--insecure"
- "${{ inputs.tls-skip-verify }}"
- "--env"
- "${{ inputs.env-data }}"

364
main.go
View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"flag"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -12,51 +13,115 @@ import (
"time" "time"
) )
// -------------------------------
// 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 // Data structures
// ------------------------------- // -------------------------------
type Stacks []Stack
type Stack struct { type Stack struct {
ID int `json:"Id"` 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"` Name string `json:"Name"`
EndpointId int `json:"EndpointId"` 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"` GitConfig GitConfig `json:"gitConfig"`
Namespace string `json:"namespace"`
ProjectPath string `json:"projectPath"`
UpdateDate int64 `json:"updateDate"`
UpdatedBy string `json:"updatedBy"`
} }
type GitConfig struct { type AutoUpdate struct {
URL string `json:"url"` ForcePullImage bool `json:"forcePullImage"`
ReferenceName string `json:"referenceName"` ForceUpdate bool `json:"forceUpdate"`
ConfigFilePath string `json:"configFilePath"` Interval string `json:"interval"`
JobID string `json:"jobID"`
Webhook string `json:"webhook"`
} }
type EnvVar struct { type Env struct {
Name string `json:"name"` Name string `json:"name"`
Value string `json:"value"` Value string `json:"value"`
} }
// ------------------------------- type GitConfig struct {
// Environment-driven configuration Authentication Authentication `json:"authentication"`
// ------------------------------- ConfigFilePath string `json:"configFilePath"`
ConfigHash string `json:"configHash"`
ReferenceName string `json:"referenceName"`
TlsskipVerify bool `json:"tlsskipVerify"`
URL string `json:"url"`
}
var ( type Authentication struct {
portainerURL = os.Getenv("PORTAINER_URL") AuthorizationType int64 `json:"authorizationType"`
portainerToken = os.Getenv("PORTAINER_TOKEN") GitCredentialID int64 `json:"gitCredentialID"`
endpointIDStr = os.Getenv("PORTAINER_ENDPOINT") Password string `json:"password"`
stackName = os.Getenv("STACK_NAME") Username string `json:"username"`
repoURL = os.Getenv("REPO_URL") }
repoRef = os.Getenv("REPO_REF")
repoComposeFile = os.Getenv("REPO_COMPOSE_FILE") type Option struct {
repoUsername = os.Getenv("REPO_USERNAME") Prune bool `json:"prune"`
repoPassword = os.Getenv("REPO_PASSWORD") }
tlsSkipVerify = os.Getenv("TLS_SKIP_VERIFY")
envData = os.Getenv("ENV_DATA") 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"`
}
// ------------------------------- // -------------------------------
// Helpers // HTTP helpers
// ------------------------------- // -------------------------------
func request(client *http.Client, method, url string, body interface{}) (*http.Response, error) { func request(client *http.Client, token, method, url string, body interface{}) (*http.Response, error) {
var buf io.Reader var buf io.Reader
if body != nil { if body != nil {
data, err := json.Marshal(body) data, err := json.Marshal(body)
@@ -71,7 +136,7 @@ func request(client *http.Client, method, url string, body interface{}) (*http.R
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("X-API-KEY", portainerToken) req.Header.Set("X-API-KEY", token)
if method != "GET" { if method != "GET" {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
@@ -80,129 +145,196 @@ func request(client *http.Client, method, url string, body interface{}) (*http.R
} }
// ------------------------------- // -------------------------------
// Main logic // Stack deployment logic
// ------------------------------- // -------------------------------
func main() { func Deploy(cfg Config) error {
// Validate required environment variables client := &http.Client{Timeout: 15 * time.Second}
if portainerURL == "" || portainerToken == "" || endpointIDStr == "" || repoURL == "" || repoRef == "" || repoComposeFile == "" || stackName == "" { if cfg.TLSSkipVerify {
fmt.Println("❌ Missing required environment variables.")
os.Exit(1)
}
// Convert endpointID to integer
endpointID, err := strconv.Atoi(endpointIDStr)
if err != nil {
fmt.Printf("❌ Invalid PORTAINER_ENDPOINT value: %v\n", err)
os.Exit(1)
}
// Configure HTTP client
client := &http.Client{
Timeout: 15 * time.Second,
}
if tlsSkipVerify == "true" {
client.Transport = &http.Transport{ client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
} }
} }
// 1. Fetch existing stacks var envVars []Env
resp, err := request(client, "GET", fmt.Sprintf("%s/stacks", portainerURL), nil) if cfg.EnvData != "" {
if err != nil { if err := json.Unmarshal([]byte(cfg.EnvData), &envVars); err != nil {
fmt.Printf("❌ Error fetching stacks: %v\n", err) return fmt.Errorf("invalid env data: %w", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("❌ Error fetching stacks: %s\n", string(body))
os.Exit(1)
}
var stacks []Stack
if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
fmt.Printf("❌ Failed to parse stacks response: %v\n", err)
os.Exit(1)
}
// 2. Find existing stack
var existing *Stack
for _, s := range stacks {
if s.GitConfig.URL == repoURL && s.Name == stackName {
existing = &s
break
} }
} }
// 3. Parse environment variables
var envVars []EnvVar
if envData != "" {
if err := json.Unmarshal([]byte(envData), &envVars); err != nil {
fmt.Printf("❌ Error parsing ENV_DATA: %v\n", err)
os.Exit(1)
}
}
if existing == nil {
// 4. Create new stack
payload := map[string]interface{}{ payload := map[string]interface{}{
"composeFile": repoComposeFile, "method": "repository",
"env": envVars, "type": "standalone",
"fromAppTemplate": false, "Name": cfg.StackName,
"name": stackName, "RepositoryURL": cfg.RepoURL,
"repositoryAuthentication": repoUsername != "" && repoPassword != "", "RepositoryReferenceName": cfg.RepoRef,
"repositoryUsername": repoUsername, "ComposeFile": cfg.RepoComposeFile,
"repositoryPassword": repoPassword, "AdditionalFiles": []string{},
"repositoryReferenceName": repoRef, "RepositoryAuthenticationType": 0,
"repositoryURL": repoURL, "RepositoryUsername": cfg.RepoUsername,
"tlsskipVerify": tlsSkipVerify == "true", "RepositoryPassword": cfg.RepoPassword,
"Env": envVars,
"TLSSkipVerify": cfg.TLSSkipVerify,
"AutoUpdate": map[string]interface{}{
"Interval": "",
"Webhook": "",
"ForceUpdate": false,
"ForcePullImage": false,
},
} }
url := fmt.Sprintf("%s/stacks/create/standalone/repository?endpointId=%d", portainerURL, endpointID) url := fmt.Sprintf("%s/stacks/create/standalone/repository?endpointId=%d", cfg.PortainerURL, cfg.EndpointID)
resp, err = request(client, "POST", url, payload) resp, err := request(client, cfg.PortainerToken, "POST", url, payload)
if err != nil { if err != nil {
fmt.Printf("❌ Error creating stack: %v\n", err) return fmt.Errorf("create stack: %w", err)
os.Exit(1)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
fmt.Printf("❌ Error creating stack: %s\n", string(body)) return fmt.Errorf("error creating stack: %s", string(body))
os.Exit(1) }
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)
}
} }
fmt.Printf("✅ Stack %s deployed successfully.\n", stackName)
} else {
// 5. Redeploy existing stack
payload := map[string]interface{}{ payload := map[string]interface{}{
"env": envVars, "env": envVars,
"prune": true, "prune": false,
"pullImage": true, "PullImage": true,
"repositoryAuthentication": repoUsername != "" && repoPassword != "", "repositoryAuthentication": true,
"repositoryAuthorizationType": 0, "RepositoryReferenceName": "HEAD",
"repositoryUsername": repoUsername, "repositoryPassword": cfg.RepoPassword,
"repositoryPassword": repoPassword, "repositoryUsername": cfg.RepoUsername,
"repositoryReferenceName": repoRef, "stackName": cfg.StackName,
"stackName": stackName,
} }
url := fmt.Sprintf("%s/stacks/%d/git/redeploy?endpointId=%d", portainerURL, existing.ID, endpointID) url := fmt.Sprintf("%s/stacks/%d/git/redeploy?endpointId=%d", cfg.PortainerURL, s.ID, cfg.EndpointID)
resp, err = request(client, "PUT", url, payload) resp, err := request(client, cfg.PortainerToken, "PUT", url, payload)
if err != nil { if err != nil {
fmt.Printf("❌ Error redeploying stack: %v\n", err) return fmt.Errorf("redeploy stack: %w", err)
os.Exit(1)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
fmt.Printf("❌ Error redeploying stack: %s\n", string(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) os.Exit(1)
} }
fmt.Printf("♻️ Stack %s redeployed successfully.\n", stackName) 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, "❌ %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, "❌ %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
if err := Deploy(cfg); err != nil {
fmt.Fprintf(os.Stderr, "❌ %v\n", err)
os.Exit(1)
}
os.Exit(0)
} }