diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml deleted file mode 100644 index 81aa2d5..0000000 --- a/.gitea/workflows/deploy.yml +++ /dev/null @@ -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 }} diff --git a/Dockerfile b/Dockerfile index ef939e7..3536e04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,14 @@ # Stage 1: Build Go binary FROM golang:1.23-alpine AS builder WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download 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 FROM alpine:3.20 RUN apk add --no-cache ca-certificates COPY --from=builder /portainer-deploy /usr/local/bin/portainer-deploy -ENTRYPOINT ["portainer-deploy"] +USER nobody +ENTRYPOINT ["/usr/local/bin/portainer-deploy"] diff --git a/main.go b/main.go index ffd16ce..283b6de 100644 --- a/main.go +++ b/main.go @@ -2,11 +2,14 @@ package main import ( "bytes" + "crypto/tls" "encoding/json" "fmt" "io" "net/http" "os" + "strconv" + "time" ) // ------------------------------- @@ -36,17 +39,17 @@ type EnvVar struct { // ------------------------------- var ( - portainerURL = os.Getenv("PORTAINER_URL") // e.g. https://portainer.example.com/api - portainerToken = os.Getenv("PORTAINER_TOKEN") // API access token - endpointID = os.Getenv("PORTAINER_ENDPOINT") // Portainer endpoint ID - stackName = os.Getenv("STACK_NAME") // Name of stack to create/redeploy - repoURL = os.Getenv("REPO_URL") // Git repo URL - repoRef = os.Getenv("REPO_REF") // e.g. refs/heads/main - repoComposeFile = os.Getenv("REPO_COMPOSE_FILE") // e.g. docker-compose.yml - repoUsername = os.Getenv("REPO_USERNAME") // Git username (if needed) - repoPassword = os.Getenv("REPO_PASSWORD") // Git password/token (if needed) - tlsSkipVerify = os.Getenv("TLS_SKIP_VERIFY") // "true" / "false" - envData = os.Getenv("ENV_DATA") // Optional env vars in JSON format + 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") ) // ------------------------------- @@ -56,13 +59,18 @@ var ( func request(client *http.Client, method, url string, body interface{}) (*http.Response, error) { var buf io.Reader 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) } + req, err := http.NewRequest(method, url, buf) 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("Content-Type", "application/json") return client.Do(req) @@ -73,32 +81,50 @@ func request(client *http.Client, method, url string, body interface{}) (*http.R // ------------------------------- func main() { - if portainerURL == "" || portainerToken == "" || endpointID == "" || repoURL == "" || repoRef == "" || repoComposeFile == "" || stackName == "" { - fmt.Println("Missing required environment variables.") + // Validate required environment variables + if portainerURL == "" || portainerToken == "" || endpointIDStr == "" || repoURL == "" || repoRef == "" || repoComposeFile == "" || stackName == "" { + fmt.Println("❌ Missing required environment variables.") 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 resp, err := request(client, "GET", fmt.Sprintf("%s/stacks", portainerURL), nil) if err != nil { - panic(err) + fmt.Printf("❌ Error fetching stacks: %v\n", 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)) + 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 { - 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 for _, s := range stacks { if s.GitConfig.URL == repoURL && s.Name == stackName { @@ -108,10 +134,10 @@ func main() { } // 3. Parse environment variables - var envVars []EnvVar = []EnvVar{} + var envVars []EnvVar if envData != "" { 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) } } @@ -131,22 +157,23 @@ func main() { "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) if err != nil { - panic(err) + 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)) + fmt.Printf("❌ Error creating stack: %s\n", string(body)) os.Exit(1) } fmt.Printf("✅ Stack %s deployed successfully.\n", stackName) } else { - // 5. Redeploy stack + // 5. Redeploy existing stack payload := map[string]interface{}{ "env": envVars, "prune": true, @@ -159,16 +186,17 @@ func main() { "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) if err != nil { - panic(err) + 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)) + fmt.Printf("❌ Error redeploying stack: %s\n", string(body)) os.Exit(1) }