chore: update main.go
This commit is contained in:
35
action.yml
35
action.yml
@@ -45,15 +45,26 @@ inputs:
|
||||
runs:
|
||||
using: "docker"
|
||||
image: "Dockerfile"
|
||||
env:
|
||||
PORTAINER_URL: ${{ inputs.portainer-url }}
|
||||
PORTAINER_TOKEN: ${{ inputs.portainer-token }}
|
||||
PORTAINER_ENDPOINT: ${{ inputs.portainer-endpoint }}
|
||||
STACK_NAME: ${{ inputs.stack-name }}
|
||||
REPO_URL: ${{ inputs.repo-url }}
|
||||
REPO_REF: ${{ inputs.repo-ref }}
|
||||
REPO_COMPOSE_FILE: ${{ inputs.repo-compose-file }}
|
||||
REPO_USERNAME: ${{ inputs.repo-username }}
|
||||
REPO_PASSWORD: ${{ inputs.repo-password }}
|
||||
TLS_SKIP_VERIFY: ${{ inputs.tls-skip-verify }}
|
||||
ENV_DATA: ${{ inputs.env-data }}
|
||||
args:
|
||||
- "--url"
|
||||
- "${{ inputs.portainer-url }}"
|
||||
- "--token"
|
||||
- "${{ inputs.portainer-token }}"
|
||||
- "--endpoint"
|
||||
- "${{ inputs.portainer-endpoint }}"
|
||||
- "--stack"
|
||||
- "${{ inputs.stack-name }}"
|
||||
- "--repo-url"
|
||||
- "${{ inputs.repo-url }}"
|
||||
- "--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
416
main.go
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,197 +13,328 @@ import (
|
||||
"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
|
||||
// -------------------------------
|
||||
|
||||
type Stacks []Stack
|
||||
|
||||
type Stack struct {
|
||||
ID int `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
EndpointId int `json:"EndpointId"`
|
||||
GitConfig GitConfig `json:"gitConfig"`
|
||||
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 GitConfig struct {
|
||||
URL string `json:"url"`
|
||||
ReferenceName string `json:"referenceName"`
|
||||
ConfigFilePath string `json:"configFilePath"`
|
||||
type AutoUpdate struct {
|
||||
ForcePullImage bool `json:"forcePullImage"`
|
||||
ForceUpdate bool `json:"forceUpdate"`
|
||||
Interval string `json:"interval"`
|
||||
JobID string `json:"jobID"`
|
||||
Webhook string `json:"webhook"`
|
||||
}
|
||||
|
||||
type EnvVar struct {
|
||||
type Env struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// Environment-driven configuration
|
||||
// -------------------------------
|
||||
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"`
|
||||
}
|
||||
|
||||
var (
|
||||
portainerURL = os.Getenv("PORTAINER_URL")
|
||||
portainerToken = os.Getenv("PORTAINER_TOKEN")
|
||||
endpointIDStr = os.Getenv("PORTAINER_ENDPOINT")
|
||||
stackName = os.Getenv("STACK_NAME")
|
||||
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 Authentication struct {
|
||||
AuthorizationType int64 `json:"authorizationType"`
|
||||
GitCredentialID int64 `json:"gitCredentialID"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// Helpers
|
||||
// -------------------------------
|
||||
type Option struct {
|
||||
Prune bool `json:"prune"`
|
||||
}
|
||||
|
||||
func request(client *http.Client, 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)
|
||||
}
|
||||
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"`
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
type TeamAccess struct {
|
||||
AccessLevel int64 `json:"AccessLevel"`
|
||||
TeamID int64 `json:"TeamId"`
|
||||
}
|
||||
|
||||
req.Header.Set("X-API-KEY", portainerToken)
|
||||
if method != "GET" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
return client.Do(req)
|
||||
type UserAccess struct {
|
||||
AccessLevel int64 `json:"AccessLevel"`
|
||||
UserID int64 `json:"UserId"`
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// Main logic
|
||||
// HTTP helpers
|
||||
// -------------------------------
|
||||
|
||||
func main() {
|
||||
// Validate required environment variables
|
||||
if portainerURL == "" || portainerToken == "" || endpointIDStr == "" || repoURL == "" || repoRef == "" || repoComposeFile == "" || stackName == "" {
|
||||
fmt.Println("❌ Missing required environment variables.")
|
||||
os.Exit(1)
|
||||
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)
|
||||
}
|
||||
|
||||
// Convert endpointID to integer
|
||||
endpointID, err := strconv.Atoi(endpointIDStr)
|
||||
req, err := http.NewRequest(method, url, buf)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Invalid PORTAINER_ENDPOINT value: %v\n", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Configure HTTP client
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
req.Header.Set("X-API-KEY", token)
|
||||
if method != "GET" {
|
||||
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{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Fetch existing stacks
|
||||
resp, err := request(client, "GET", fmt.Sprintf("%s/stacks", portainerURL), nil)
|
||||
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{}{
|
||||
"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 {
|
||||
fmt.Printf("❌ Error fetching stacks: %v\n", err)
|
||||
os.Exit(1)
|
||||
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)
|
||||
fmt.Printf("❌ Error fetching stacks: %s\n", string(body))
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("error redeploying stack: %s", string(body))
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Printf("❌ Failed to parse stacks response: %v\n", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("decode stacks: %w", err)
|
||||
}
|
||||
|
||||
// 2. Find existing stack
|
||||
var existing *Stack
|
||||
fmt.Printf("Stack data: %+v\n", stacks)
|
||||
|
||||
var existing *Stack = nil
|
||||
for _, s := range stacks {
|
||||
if s.GitConfig.URL == repoURL && s.Name == stackName {
|
||||
if s.GitConfig.URL == cfg.RepoURL && s.Name == cfg.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{}{
|
||||
"composeFile": repoComposeFile,
|
||||
"env": envVars,
|
||||
"fromAppTemplate": false,
|
||||
"name": stackName,
|
||||
"repositoryAuthentication": repoUsername != "" && repoPassword != "",
|
||||
"repositoryUsername": repoUsername,
|
||||
"repositoryPassword": repoPassword,
|
||||
"repositoryReferenceName": repoRef,
|
||||
"repositoryURL": repoURL,
|
||||
"tlsskipVerify": tlsSkipVerify == "true",
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/stacks/create/standalone/repository?endpointId=%d", portainerURL, endpointID)
|
||||
resp, err = request(client, "POST", url, payload)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Error creating stack: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("❌ Error creating stack: %s\n", string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Stack %s deployed successfully.\n", stackName)
|
||||
} else {
|
||||
// 5. Redeploy existing stack
|
||||
payload := map[string]interface{}{
|
||||
"env": envVars,
|
||||
"prune": true,
|
||||
"pullImage": true,
|
||||
"repositoryAuthentication": repoUsername != "" && repoPassword != "",
|
||||
"repositoryAuthorizationType": 0,
|
||||
"repositoryUsername": repoUsername,
|
||||
"repositoryPassword": repoPassword,
|
||||
"repositoryReferenceName": repoRef,
|
||||
"stackName": stackName,
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/stacks/%d/git/redeploy?endpointId=%d", portainerURL, existing.ID, endpointID)
|
||||
resp, err = request(client, "PUT", url, payload)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Error redeploying stack: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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, "❌ %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)
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user