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 }}"

416
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,197 +13,328 @@ 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"`
Name string `json:"Name"` AutoUpdate AutoUpdate `json:"AutoUpdate"`
EndpointId int `json:"EndpointId"` EndpointID int64 `json:"EndpointId"`
GitConfig GitConfig `json:"gitConfig"` 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 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")
repoUsername = os.Getenv("REPO_USERNAME")
repoPassword = os.Getenv("REPO_PASSWORD")
tlsSkipVerify = os.Getenv("TLS_SKIP_VERIFY")
envData = os.Getenv("ENV_DATA")
)
// ------------------------------- type Option struct {
// Helpers Prune bool `json:"prune"`
// ------------------------------- }
func request(client *http.Client, method, url string, body interface{}) (*http.Response, error) { type ResourceControl struct {
var buf io.Reader AccessLevel int64 `json:"AccessLevel"`
if body != nil { AdministratorsOnly bool `json:"AdministratorsOnly"`
data, err := json.Marshal(body) ID int64 `json:"Id"`
if err != nil { OwnerID int64 `json:"OwnerId"`
return nil, fmt.Errorf("failed to marshal request body: %w", err) Public bool `json:"Public"`
} ResourceID string `json:"ResourceId"`
buf = bytes.NewBuffer(data) SubResourceIDS []string `json:"SubResourceIds"`
} System bool `json:"System"`
TeamAccesses []TeamAccess `json:"TeamAccesses"`
Type int64 `json:"Type"`
UserAccesses []UserAccess `json:"UserAccesses"`
}
req, err := http.NewRequest(method, url, buf) type TeamAccess struct {
if err != nil { AccessLevel int64 `json:"AccessLevel"`
return nil, fmt.Errorf("failed to create request: %w", err) TeamID int64 `json:"TeamId"`
} }
req.Header.Set("X-API-KEY", portainerToken) type UserAccess struct {
if method != "GET" { AccessLevel int64 `json:"AccessLevel"`
req.Header.Set("Content-Type", "application/json") UserID int64 `json:"UserId"`
}
return client.Do(req)
} }
// ------------------------------- // -------------------------------
// Main logic // HTTP helpers
// ------------------------------- // -------------------------------
func main() { func request(client *http.Client, token, method, url string, body interface{}) (*http.Response, error) {
// Validate required environment variables var buf io.Reader
if portainerURL == "" || portainerToken == "" || endpointIDStr == "" || repoURL == "" || repoRef == "" || repoComposeFile == "" || stackName == "" { if body != nil {
fmt.Println("❌ Missing required environment variables.") data, err := json.Marshal(body)
os.Exit(1) if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
buf = bytes.NewBuffer(data)
} }
// Convert endpointID to integer req, err := http.NewRequest(method, url, buf)
endpointID, err := strconv.Atoi(endpointIDStr)
if err != nil { if err != nil {
fmt.Printf("❌ Invalid PORTAINER_ENDPOINT value: %v\n", err) return nil, fmt.Errorf("failed to create request: %w", err)
os.Exit(1)
} }
// Configure HTTP client req.Header.Set("X-API-KEY", token)
client := &http.Client{ if method != "GET" {
Timeout: 15 * time.Second, req.Header.Set("Content-Type", "application/json")
} }
if tlsSkipVerify == "true" {
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{ 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 := json.Unmarshal([]byte(cfg.EnvData), &envVars); err != nil {
return fmt.Errorf("invalid env data: %w", err)
}
}
payload := map[string]interface{}{
"method": "repository",
"type": "standalone",
"Name": cfg.StackName,
"RepositoryURL": cfg.RepoURL,
"RepositoryReferenceName": cfg.RepoRef,
"ComposeFile": cfg.RepoComposeFile,
"AdditionalFiles": []string{},
"RepositoryAuthenticationType": 0,
"RepositoryUsername": cfg.RepoUsername,
"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", cfg.PortainerURL, cfg.EndpointID)
resp, err := request(client, cfg.PortainerToken, "POST", url, payload)
if err != nil { if err != nil {
fmt.Printf("❌ Error fetching stacks: %v\n", err) return fmt.Errorf("create stack: %w", err)
os.Exit(1) }
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() 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 fetching stacks: %s\n", string(body)) return fmt.Errorf("error redeploying stack: %s", string(body))
os.Exit(1)
} }
var stacks []Stack 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 { if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
fmt.Printf("❌ Failed to parse stacks response: %v\n", err) return nil, fmt.Errorf("decode stacks: %w", err)
os.Exit(1)
} }
// 2. Find existing stack fmt.Printf("Stack data: %+v\n", stacks)
var existing *Stack
var existing *Stack = nil
for _, s := range stacks { for _, s := range stacks {
if s.GitConfig.URL == repoURL && s.Name == stackName { if s.GitConfig.URL == cfg.RepoURL && s.Name == cfg.StackName {
existing = &s existing = &s
break break
} }
} }
// 3. Parse environment variables return existing, nil
var envVars []EnvVar }
if envData != "" {
if err := json.Unmarshal([]byte(envData), &envVars); err != nil { // -------------------------------
fmt.Printf("❌ Error parsing ENV_DATA: %v\n", err) // CLI setup
os.Exit(1) // -------------------------------
}
} func main() {
cfg := Config{}
if existing == nil { flag.StringVar(&cfg.PortainerURL, "url", "", "Portainer API base URL (e.g., https://portainer.example.com/api)")
// 4. Create new stack flag.StringVar(&cfg.PortainerToken, "token", "", "Portainer API key or token")
payload := map[string]interface{}{ endpointID := flag.String("endpoint", "", "Portainer endpoint ID")
"composeFile": repoComposeFile, flag.StringVar(&cfg.StackName, "stack", "", "Stack name")
"env": envVars, flag.StringVar(&cfg.RepoURL, "repo-url", "", "Repository URL")
"fromAppTemplate": false, flag.StringVar(&cfg.RepoRef, "repo-ref", "main", "Git reference/branch")
"name": stackName, flag.StringVar(&cfg.RepoComposeFile, "compose", "docker-compose.yml", "Path to Compose file in repo")
"repositoryAuthentication": repoUsername != "" && repoPassword != "", flag.StringVar(&cfg.RepoUsername, "repo-user", "", "Repository username (optional)")
"repositoryUsername": repoUsername, flag.StringVar(&cfg.RepoPassword, "repo-pass", "", "Repository password (optional)")
"repositoryPassword": repoPassword, flag.BoolVar(&cfg.TLSSkipVerify, "insecure", false, "Skip TLS certificate verification")
"repositoryReferenceName": repoRef, flag.StringVar(&cfg.EnvData, "env", "", "Environment variables in JSON format, e.g. '[{\"name\":\"VAR\",\"value\":\"123\"}]'")
"repositoryURL": repoURL, flag.Parse()
"tlsskipVerify": tlsSkipVerify == "true",
} if cfg.PortainerURL == "" || cfg.PortainerToken == "" ||
cfg.RepoURL == "" || cfg.RepoRef == "" || cfg.RepoComposeFile == "" || cfg.StackName == "" {
url := fmt.Sprintf("%s/stacks/create/standalone/repository?endpointId=%d", portainerURL, endpointID) flag.Usage()
resp, err = request(client, "POST", url, payload) os.Exit(1)
if err != nil { }
fmt.Printf("❌ Error creating stack: %v\n", err)
os.Exit(1) id, err := strconv.Atoi(*endpointID)
} if err != nil {
defer resp.Body.Close() fmt.Fprintf(os.Stderr, "Invalid endpoint ID: %v\n", err)
os.Exit(1)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { }
body, _ := io.ReadAll(resp.Body) cfg.EndpointID = id
fmt.Printf("❌ Error creating stack: %s\n", string(body))
os.Exit(1) stack, err := findStack(cfg)
} if err != nil {
fmt.Fprintf(os.Stderr, "❌ %v\n", err)
fmt.Printf("✅ Stack %s deployed successfully.\n", stackName) os.Exit(1)
} else { }
// 5. Redeploy existing stack
payload := map[string]interface{}{ if stack != nil {
"env": envVars, fmt.Printf("✅ Found existing stack: %+v\n", stack)
"prune": true,
"pullImage": true, if err := stack.Redeploy(cfg); err != nil {
"repositoryAuthentication": repoUsername != "" && repoPassword != "", fmt.Fprintf(os.Stderr, "❌ %v\n", err)
"repositoryAuthorizationType": 0, os.Exit(1)
"repositoryUsername": repoUsername, }
"repositoryPassword": repoPassword,
"repositoryReferenceName": repoRef, os.Exit(0)
"stackName": stackName, }
}
if err := Deploy(cfg); err != nil {
url := fmt.Sprintf("%s/stacks/%d/git/redeploy?endpointId=%d", portainerURL, existing.ID, endpointID) fmt.Fprintf(os.Stderr, "❌ %v\n", err)
resp, err = request(client, "PUT", url, payload) os.Exit(1)
if err != nil { }
fmt.Printf("❌ Error redeploying stack: %v\n", err)
os.Exit(1) os.Exit(0)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("❌ Error redeploying stack: %s\n", string(body))
os.Exit(1)
}
fmt.Printf("♻️ Stack %s redeployed successfully.\n", stackName)
}
} }