Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ Portainer-stack-deploy is a GitHub Action for deploying a newly updated stack to

**Currently works on Portainer API v2.**

**This repo is a fork of [carlrygart/portainer-stack-deploy](https://github.com/carlrygart/portainer-stack-deploy) with some minor changes.**
**This repo is a fork of [OidaGroup/portainer-stack-deploy](https://github.com/carlrygart/portainer-stack-deploy) with some minor changes.**

## Action Inputs

| Input | Description | Default |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
|--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------ |
| portainer-host | Portainer host, eg. `https://myportainer.instance.com` | **Required** |
| username | Username for the Portainer login. **NOTE: Do not use admin account!** Create a new CI specific login instead | **Required** |
| password | Password for the Portainer login | **Required** |
Expand All @@ -22,6 +22,7 @@ Portainer-stack-deploy is a GitHub Action for deploying a newly updated stack to
| stack-name | Name for the Portainer stack | **Required** |
| stack-definition | The path to the docker-compose stack stack definition file from repo root, eg. `stack-definition.yml` | **Required** |
| template-variables | If given, these variables will be replaced in docker-compose file by handlebars | |
| env-variables | Path to a file containing environment variables to be passed to the stack. The file should be in the format `KEY=VALUE` and will be sourced into the stack definition. | |
| image | The URI of the container image to insert into the stack definition, eg. `ghcr.io/username/repo:sha-676cae2`. Will use existing image inside stack definition if not provided | |
| prune-stack | If set to `true`, the action will remove any services that are not defined in the stack definition. | false |
| pull-image | If set to `true`, the action will pull the image before deploying the stack. | false |
Expand Down Expand Up @@ -84,6 +85,9 @@ jobs:
stack-name: 'my-awesome-web-app'
stack-definition: 'stack-definition.yml'
template-variables: '{"username": "MrCool"}'
env-variables: |
MY_ENV_VAR=some-value
ANOTHER_ENV_VAR=another-value
image: ${{ env.DOCKER_IMAGE_URI }}:${{ env.IMAGE_TAG }}
prune-stack: true
pull-image: true
Expand Down
47 changes: 46 additions & 1 deletion __tests__/deployStack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ describe('deployStack', () => {
endpointId: 1
})
.reply(200)

await deployStack({
portainerHost: 'http://mock.url',
username: 'username',
password: 'password',
swarmId: 's4ny2nh7qt8lluhvddeu9ulwl',
endpointId: 1,
stackName: 'new-stack-name',
stackDefinitionFile: 'example-stack-definition.yml',
image: 'ghcr.io/username/repo:sha-0142c14'
})
nock.isDone()
})

test('deploy compose stack', async () => {
Expand Down Expand Up @@ -111,7 +123,10 @@ describe('deployStack', () => {
.matchHeader('authorization', 'Bearer token')
.matchHeader('content-type', 'application/json')
.put('/stacks/3', {
env: [{ name: 'keyName', value: 'value1' }],
env: [
{ name: 'keyName', value: 'value1' },
{ name: '123', value: '123' }
],
prune: false,
pullImage: false,
stackFileContent:
Expand All @@ -129,6 +144,7 @@ describe('deployStack', () => {
endpointId: 1,
stackName: 'stack-name-with-env',
stackDefinitionFile: 'example-stack-definition.yml',
envVariables: [{ name: '123', value: '123' }],
image: 'ghcr.io/username/repo:sha-0142c14'
})

Expand Down Expand Up @@ -220,4 +236,33 @@ describe('deployStack', () => {

nock.isDone()
})

test('All image tags are replaced', async () => {
nock(BASE_API_URL)
.matchHeader('authorization', 'Bearer token')
.matchHeader('content-type', 'application/json')
.post('/stacks/create/standalone/string', {
name: 'new-stack-name',
stackFileContent:
"version: '3.7'\n\nservices:\n server1:\n image: ghcr.io/username/repo:1.2.3\n deploy:\n update_config:\n order: start-first\n server2:\n image: ghcr.io/username/repo:1.2.3\n deploy:\n update_config:\n order: start-first\n"
})
.query({
type: 2,
method: 'string',
endpointId: 1
})
.reply(200)

await deployStack({
portainerHost: 'http://mock.url',
username: 'username',
password: 'password',
endpointId: 1,
stackName: 'new-stack-name',
stackDefinitionFile: 'example-stack-definition-multi-image.yml',
image: 'ghcr.io/username/repo:1.2.3'
})

nock.isDone()
})
})
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ inputs:
template-variables:
required: false
description: "If given, these variables will be replaced in docker-compose file by handlebars"
env-variables:
required: false
description: "Environment variables to set in the stack, eg. VARIABLE1=value1\nVARIABLE2=value2"
image:
required: false
description: "The URI of the container image to insert into the stack definition, eg. docker.pkg.github.com/username/repo/master"
Expand Down
58 changes: 54 additions & 4 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions example-stack-definition-multi-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: '3.7'

services:
server1:
image: ghcr.io/username/repo:latest
deploy:
update_config:
order: start-first
server2:
image: ghcr.io/username/repo:latest
deploy:
update_config:
order: start-first
9 changes: 7 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axios from 'axios'

type EnvVariables = Array<{
export type EnvVariables = Array<{
name: string
value: string
}>
Expand All @@ -15,7 +15,12 @@ type StackData = {
}

type CreateStackParams = { type: number; method: string; endpointId: EndpointId }
type CreateStackBody = { name: string; stackFileContent: string; swarmID?: string }
type CreateStackBody = {
name: string
stackFileContent: string
swarmID?: string
Env?: EnvVariables
}
type UpdateStackParams = { endpointId: EndpointId }
type UpdateStackBody = {
env: EnvVariables
Expand Down
59 changes: 57 additions & 2 deletions src/deployStack.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PortainerApi } from './api'
import { EnvVariables } from './api'
import path from 'path'
import fs from 'fs'
import Handlebars from 'handlebars'
Expand All @@ -13,6 +14,7 @@ type DeployStack = {
stackName: string
stackDefinitionFile?: string
templateVariables?: object
envVariables?: EnvVariables
image?: string
pruneStack?: boolean
pullImage?: boolean
Expand All @@ -23,6 +25,40 @@ enum StackType {
COMPOSE = 2
}

export function parseEnvVariables(envVariables: string): EnvVariables {
const normalizedEnvVariables = envVariables.replace(/\\n/g, '\n').trim()
core.debug(`Parsing env variables: ${normalizedEnvVariables.replace(/\n/g, '\n')}`)
return normalizedEnvVariables
.split('\n')
.filter(line => line.trim() !== '')
.map(line => {
const [name, ...rest] = line.split('=')
const value = rest.join('=').trim()
core.debug(`Parsing env variable: ${name}=${value.replace(/\n/g, '\n')}`)
return {
name: name.trim(),
value: value
}
})
}

export function mergeEnvVariables(original: EnvVariables, updates: EnvVariables): EnvVariables {
const mergedMap = new Map<string, string>()

// Step 1: Add original variables
for (const variable of original) {
mergedMap.set(variable.name, variable.value)
}

// Step 2: Apply updates (overwrite or add)
for (const variable of updates) {
mergedMap.set(variable.name, variable.value)
}

// Step 3: Convert map back to array
return Array.from(mergedMap.entries()).map(([name, value]) => ({ name, value }))
}

function generateNewStackDefinition(
stackDefinitionFile?: string,
templateVariables?: object,
Expand Down Expand Up @@ -52,7 +88,12 @@ function generateNewStackDefinition(

const imageWithoutTag = image.substring(0, image.indexOf(':'))
core.info(`Inserting image ${image} into the stack definition`)
return stackDefinition.replace(new RegExp(`${imageWithoutTag}(:.*)?\n`, 'g'), `${image}\n`)
const output = stackDefinition.replace(
new RegExp(`${imageWithoutTag}(:.*)?\n`, 'g'),
`${image}\n`
)
core.debug(`Updated stack definition:\n${output}`)
return output
}

export async function deployStack({
Expand All @@ -64,6 +105,7 @@ export async function deployStack({
stackName,
stackDefinitionFile,
templateVariables,
envVariables,
image,
pruneStack,
pullImage
Expand Down Expand Up @@ -92,6 +134,18 @@ export async function deployStack({
if (existingStack) {
core.info(`Found existing stack with name: ${stackName}`)
core.info('Updating existing stack...')

if (envVariables) {
if (!existingStack.Env) {
existingStack.Env = []
}
core.debug(`Updating environment variables for stack: ${stackName}`)
core.debug(`Old environment variables: ${JSON.stringify(existingStack.Env)}`)
existingStack.Env = mergeEnvVariables(existingStack.Env, envVariables)
core.info(`Updated environment variables: ${JSON.stringify(existingStack.Env)}`)
} else {
core.info('No environment variables provided, keeping existing ones.')
}
await portainerApi.updateStack(
existingStack.Id,
{
Expand Down Expand Up @@ -121,7 +175,8 @@ export async function deployStack({
{
name: stackName,
stackFileContent: stackDefinitionToDeploy,
swarmID: swarmId ? swarmId : undefined
swarmID: swarmId ? swarmId : undefined,
Env: envVariables ? envVariables : undefined
}
)
core.info(`Successfully created new stack with name: ${stackName}`)
Expand Down
6 changes: 5 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as core from '@actions/core'
import axios from 'axios'
import { deployStack } from './deployStack'
import { deployStack, parseEnvVariables } from './deployStack'

export async function run(): Promise<void> {
try {
Expand Down Expand Up @@ -28,6 +28,9 @@ export async function run(): Promise<void> {
const templateVariables: string = core.getInput('template-variables', {
required: false
})
const envVariables: string = core.getInput('env-variables', {
required: false
})
const image: string = core.getInput('image', {
required: false
})
Expand All @@ -47,6 +50,7 @@ export async function run(): Promise<void> {
stackName,
stackDefinitionFile: stackDefinitionFile ?? undefined,
templateVariables: templateVariables ? JSON.parse(templateVariables) : undefined,
envVariables: envVariables ? parseEnvVariables(envVariables) : undefined,
image,
pruneStack: pruneStack || false,
pullImage: pullImage || false
Expand Down