chore: update releases [ci skip]
This commit is contained in:
@@ -1,143 +0,0 @@
|
|||||||
name: "Portainer CI/CD Pipeline"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
stack-name:
|
|
||||||
description: "Name of the Portainer stack to deploy"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
portainer-endpoint:
|
|
||||||
description: "Portainer endpoint ID"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
repo-compose-file:
|
|
||||||
description: "Path to docker-compose file"
|
|
||||||
required: false
|
|
||||||
default: "docker-compose.yml"
|
|
||||||
type: string
|
|
||||||
image-name:
|
|
||||||
description: "Docker image name"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
secrets:
|
|
||||||
REGISTRY_USERNAME:
|
|
||||||
description: "Docker registry username"
|
|
||||||
required: true
|
|
||||||
REGISTRY_PASSWORD:
|
|
||||||
description: "Docker registry password"
|
|
||||||
required: true
|
|
||||||
CI_GITEA_TOKEN:
|
|
||||||
description: "Token for version bump and push"
|
|
||||||
required: true
|
|
||||||
PORTAINER_TOKEN:
|
|
||||||
description: "Portainer API token"
|
|
||||||
required: true
|
|
||||||
REGISTRY_URL:
|
|
||||||
description: "Docker registry URL (e.g. ghcr.io)"
|
|
||||||
required: true
|
|
||||||
PORTAINER_URL:
|
|
||||||
description: "Base URL of the Portainer API (e.g. https://portainer.example.com/api)"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# --------------------------------------------------------
|
|
||||||
# Semantic Release (Version bump + changelog)
|
|
||||||
# --------------------------------------------------------
|
|
||||||
release:
|
|
||||||
name: Semantic Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
|
||||||
IMAGE_NAME: ${{ inputs.image-name }}
|
|
||||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- name: Install semantic-release
|
|
||||||
run: npm install -g semantic-release @semantic-release/changelog @semantic-release/git @semantic-release/exec
|
|
||||||
|
|
||||||
- name: Run semantic-release
|
|
||||||
run: npx semantic-release
|
|
||||||
env:
|
|
||||||
GIT_AUTHOR_NAME: "CI Bot"
|
|
||||||
GIT_AUTHOR_EMAIL: "ci@etwo.dev"
|
|
||||||
GIT_COMMITTER_NAME: "CI Bot"
|
|
||||||
GIT_COMMITTER_EMAIL: "ci@etwo.dev"
|
|
||||||
|
|
||||||
- name: Install yq
|
|
||||||
run: |
|
|
||||||
wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq
|
|
||||||
chmod +x /usr/bin/yq
|
|
||||||
|
|
||||||
- name: Update docker-compose.yml
|
|
||||||
run: |
|
|
||||||
VERSION=$(cat .release-version)
|
|
||||||
echo "Updating docker-compose.yml with image ${IMAGE_NAME}:${VERSION}"
|
|
||||||
yq -i ".services.\"${IMAGE_NAME}\".image = \"${REGISTRY_URL}/${IMAGE_NAME}:${VERSION}\"" docker-compose.yml
|
|
||||||
|
|
||||||
- name: Commit and push docker-compose.yml changes
|
|
||||||
run: |
|
|
||||||
VERSION=$(cat .release-version)
|
|
||||||
git config user.name "CI Bot"
|
|
||||||
git config user.email "ci@etwo.dev"
|
|
||||||
git add docker-compose.yml
|
|
||||||
git commit -m "chore(release): update compose to ${VERSION} [skip ci]" || echo "No changes to commit"
|
|
||||||
git push origin HEAD
|
|
||||||
|
|
||||||
# --------------------------------------------------------
|
|
||||||
# Build and Push Docker Image
|
|
||||||
# --------------------------------------------------------
|
|
||||||
build:
|
|
||||||
name: Build and Push Docker Image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: release
|
|
||||||
env:
|
|
||||||
IMAGE_NAME: ${{ inputs.image-name }}
|
|
||||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to Docker registry
|
|
||||||
run: |
|
|
||||||
echo "$REGISTRY_PASSWORD" | docker login $REGISTRY_URL -u "$REGISTRY_USERNAME" --password-stdin
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: |
|
|
||||||
VERSION=$(cat .release-version || echo "latest")
|
|
||||||
echo "Building docker image with tag $VERSION"
|
|
||||||
docker build -t $REGISTRY_URL/${IMAGE_NAME}:$VERSION .
|
|
||||||
docker push $REGISTRY_URL/${IMAGE_NAME}:$VERSION
|
|
||||||
|
|
||||||
# --------------------------------------------------------
|
|
||||||
# Deploy to Portainer (using your Action)
|
|
||||||
# --------------------------------------------------------
|
|
||||||
deploy:
|
|
||||||
name: Deploy to Portainer
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy stack via Portainer API
|
|
||||||
uses: https://git.etwo.dev/actions/portable@v1
|
|
||||||
with:
|
|
||||||
portainer-url: ${{ secrets.PORTAINER_URL }}
|
|
||||||
portainer-token: ${{ secrets.PORTAINER_TOKEN }}
|
|
||||||
portainer-endpoint: ${{ inputs.portainer-endpoint }}
|
|
||||||
stack-name: ${{ inputs.stack-name }}
|
|
||||||
repo-url: ${{ github.server_url }}/${{ github.repository }}
|
|
||||||
repo-ref: ${{ github.ref }}
|
|
||||||
repo-compose-file: ${{ inputs.repo-compose-file }}
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
# Stage 1: Build Go binary
|
# Stage 1: Build Go binary
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.23-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN go mod tidy && go build -o /portainer-deploy main.go
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /portainer-deploy main.go
|
||||||
|
|
||||||
# Stage 2: Runtime container
|
# Stage 2: Runtime container
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates
|
||||||
COPY --from=builder /portainer-deploy /usr/local/bin/portainer-deploy
|
COPY --from=builder /portainer-deploy /usr/local/bin/portainer-deploy
|
||||||
ENTRYPOINT ["portainer-deploy"]
|
USER nobody
|
||||||
|
ENTRYPOINT ["/usr/local/bin/portainer-deploy"]
|
||||||
|
|||||||
86
main.go
86
main.go
@@ -2,11 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// -------------------------------
|
// -------------------------------
|
||||||
@@ -36,17 +39,17 @@ type EnvVar struct {
|
|||||||
// -------------------------------
|
// -------------------------------
|
||||||
|
|
||||||
var (
|
var (
|
||||||
portainerURL = os.Getenv("PORTAINER_URL") // e.g. https://portainer.example.com/api
|
portainerURL = os.Getenv("PORTAINER_URL")
|
||||||
portainerToken = os.Getenv("PORTAINER_TOKEN") // API access token
|
portainerToken = os.Getenv("PORTAINER_TOKEN")
|
||||||
endpointID = os.Getenv("PORTAINER_ENDPOINT") // Portainer endpoint ID
|
endpointIDStr = os.Getenv("PORTAINER_ENDPOINT")
|
||||||
stackName = os.Getenv("STACK_NAME") // Name of stack to create/redeploy
|
stackName = os.Getenv("STACK_NAME")
|
||||||
repoURL = os.Getenv("REPO_URL") // Git repo URL
|
repoURL = os.Getenv("REPO_URL")
|
||||||
repoRef = os.Getenv("REPO_REF") // e.g. refs/heads/main
|
repoRef = os.Getenv("REPO_REF")
|
||||||
repoComposeFile = os.Getenv("REPO_COMPOSE_FILE") // e.g. docker-compose.yml
|
repoComposeFile = os.Getenv("REPO_COMPOSE_FILE")
|
||||||
repoUsername = os.Getenv("REPO_USERNAME") // Git username (if needed)
|
repoUsername = os.Getenv("REPO_USERNAME")
|
||||||
repoPassword = os.Getenv("REPO_PASSWORD") // Git password/token (if needed)
|
repoPassword = os.Getenv("REPO_PASSWORD")
|
||||||
tlsSkipVerify = os.Getenv("TLS_SKIP_VERIFY") // "true" / "false"
|
tlsSkipVerify = os.Getenv("TLS_SKIP_VERIFY")
|
||||||
envData = os.Getenv("ENV_DATA") // Optional env vars in JSON format
|
envData = os.Getenv("ENV_DATA")
|
||||||
)
|
)
|
||||||
|
|
||||||
// -------------------------------
|
// -------------------------------
|
||||||
@@ -56,13 +59,18 @@ var (
|
|||||||
func request(client *http.Client, method, url string, body interface{}) (*http.Response, error) {
|
func request(client *http.Client, method, url string, body interface{}) (*http.Response, error) {
|
||||||
var buf io.Reader
|
var buf io.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
data, _ := json.Marshal(body)
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||||
|
}
|
||||||
buf = bytes.NewBuffer(data)
|
buf = bytes.NewBuffer(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(method, url, buf)
|
req, err := http.NewRequest(method, url, buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+portainerToken)
|
req.Header.Set("Authorization", "Bearer "+portainerToken)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
return client.Do(req)
|
return client.Do(req)
|
||||||
@@ -73,32 +81,50 @@ func request(client *http.Client, method, url string, body interface{}) (*http.R
|
|||||||
// -------------------------------
|
// -------------------------------
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if portainerURL == "" || portainerToken == "" || endpointID == "" || repoURL == "" || repoRef == "" || repoComposeFile == "" || stackName == "" {
|
// Validate required environment variables
|
||||||
fmt.Println("Missing required environment variables.")
|
if portainerURL == "" || portainerToken == "" || endpointIDStr == "" || repoURL == "" || repoRef == "" || repoComposeFile == "" || stackName == "" {
|
||||||
|
fmt.Println("❌ Missing required environment variables.")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{}
|
// 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{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Fetch existing stacks
|
// 1. Fetch existing stacks
|
||||||
resp, err := request(client, "GET", fmt.Sprintf("%s/stacks", portainerURL), nil)
|
resp, err := request(client, "GET", fmt.Sprintf("%s/stacks", portainerURL), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
fmt.Printf("❌ Error fetching stacks: %v\n", 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 fetching stacks: %s\n", string(body))
|
fmt.Printf("❌ Error fetching stacks: %s\n", string(body))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
var stacks []Stack
|
var stacks []Stack
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
|
||||||
panic(err)
|
fmt.Printf("❌ Failed to parse stacks response: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Find existing stack from same repo + name
|
// 2. Find existing stack
|
||||||
var existing *Stack
|
var existing *Stack
|
||||||
for _, s := range stacks {
|
for _, s := range stacks {
|
||||||
if s.GitConfig.URL == repoURL && s.Name == stackName {
|
if s.GitConfig.URL == repoURL && s.Name == stackName {
|
||||||
@@ -108,10 +134,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Parse environment variables
|
// 3. Parse environment variables
|
||||||
var envVars []EnvVar = []EnvVar{}
|
var envVars []EnvVar
|
||||||
if envData != "" {
|
if envData != "" {
|
||||||
if err := json.Unmarshal([]byte(envData), &envVars); err != nil {
|
if err := json.Unmarshal([]byte(envData), &envVars); err != nil {
|
||||||
fmt.Printf("Error parsing ENV_DATA: %v\n", err)
|
fmt.Printf("❌ Error parsing ENV_DATA: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,22 +157,23 @@ func main() {
|
|||||||
"tlsskipVerify": tlsSkipVerify == "true",
|
"tlsskipVerify": tlsSkipVerify == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/stacks/create/standalone/repository?endpointId=%s", portainerURL, endpointID)
|
url := fmt.Sprintf("%s/stacks/create/standalone/repository?endpointId=%d", portainerURL, endpointID)
|
||||||
resp, err = request(client, "POST", url, payload)
|
resp, err = request(client, "POST", url, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
fmt.Printf("❌ Error creating stack: %v\n", 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))
|
fmt.Printf("❌ Error creating stack: %s\n", string(body))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("✅ Stack %s deployed successfully.\n", stackName)
|
fmt.Printf("✅ Stack %s deployed successfully.\n", stackName)
|
||||||
} else {
|
} else {
|
||||||
// 5. Redeploy stack
|
// 5. Redeploy existing stack
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"env": envVars,
|
"env": envVars,
|
||||||
"prune": true,
|
"prune": true,
|
||||||
@@ -159,16 +186,17 @@ func main() {
|
|||||||
"stackName": stackName,
|
"stackName": stackName,
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/stacks/%d/git/redeploy?endpointId=%s", portainerURL, existing.ID, endpointID)
|
url := fmt.Sprintf("%s/stacks/%d/git/redeploy?endpointId=%d", portainerURL, existing.ID, endpointID)
|
||||||
resp, err = request(client, "PUT", url, payload)
|
resp, err = request(client, "PUT", url, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
fmt.Printf("❌ Error redeploying stack: %v\n", 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))
|
fmt.Printf("❌ Error redeploying stack: %s\n", string(body))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user