From 493d862ad518074f85526b8113b1f8f08fd60105 Mon Sep 17 00:00:00 2001 From: etwodev Date: Sun, 5 Oct 2025 18:32:00 +0100 Subject: [PATCH] chore: update main.go --- action.yml | 35 +++-- main.go | 416 +++++++++++++++++++++++++++++++++++------------------ 2 files changed, 297 insertions(+), 154 deletions(-) diff --git a/action.yml b/action.yml index aeef81e..87bfe75 100644 --- a/action.yml +++ b/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 }}" diff --git a/main.go b/main.go index ee77eaf..4d11cf4 100644 --- a/main.go +++ b/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) + }