Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0568234
Add Azure DevOps to GitOps pipelines
hardillb Mar 19, 2026
52c42af
Clean up
hardillb Mar 20, 2026
23b1749
Add specific icons
hardillb Mar 20, 2026
a2dfcbc
Merge branch 'main' into azure-devops-pipelines
hardillb Mar 20, 2026
a6b635c
Fix lint
hardillb Mar 20, 2026
d6f4051
Merge branch 'azure-devops-pipelines' of https://github.com/FlowFuse/…
hardillb Mar 20, 2026
536be19
Update dev ops pipeline docs for Azure
hardillb Mar 20, 2026
18d60e8
Add in option tabs for azure and github, add svgs, ensure type is upd…
n-lark Mar 20, 2026
64fa64f
Add Azure instructions
hardillb Mar 20, 2026
69aa3f3
Merge pull request #6930 from FlowFuse/6916-reusable-github-azure-sel…
hardillb Mar 20, 2026
266628c
Fix pipeline stage icons
hardillb Mar 20, 2026
e2e1a4e
fix lint
hardillb Mar 20, 2026
10db695
revert to basic icon
hardillb Mar 20, 2026
6fce124
more lint
hardillb Mar 20, 2026
8d5accc
Merge branch 'main' into azure-devops-pipelines
hardillb Mar 20, 2026
cae8a90
Fix git author attribution
hardillb Mar 24, 2026
3907085
Merge branch 'azure-devops-pipelines' of https://github.com/FlowFuse/…
hardillb Mar 24, 2026
f3cb54a
adjust placeholder based on token
hardillb Mar 24, 2026
3df687d
git username/password token swap
hardillb Mar 24, 2026
c80914f
Don't crash on failed git action
hardillb Mar 24, 2026
086664a
Merge pull request #6926 from FlowFuse/azure-docs-update
hardillb Mar 24, 2026
4f5af03
Fix placeholder and link to repo for azure
hardillb Mar 24, 2026
7a231d9
Merge branch 'azure-devops-pipelines' of https://github.com/FlowFuse/…
hardillb Mar 24, 2026
541abba
Apply suggestion from @knolleary
knolleary Mar 24, 2026
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
6 changes: 3 additions & 3 deletions docs/user/devops-pipelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ There are four types of stage to chose from:
1. **[Instance](./concepts.md#hosted-instance)** - a single Node-RED instance.
2. **[Device](./concepts.md#remote-instance)** - a single remote instance.
3. **[Device Group](./concepts.md#device-groups)** - a group of remote instances.
4. **Git Repository** - a remote GitHub repository.
4. **Git Repository** - a remote GitHub/Azure DevOps repository.
- This stage currently only supports:
- Repositories hosted on GitHub.com
- Repositories hosted on GitHub.com or dev.azure.com

### Actions

Expand Down Expand Up @@ -103,7 +103,7 @@ When a Device Group stage is triggered, it will push the current active snapshot

#### Git Repository stage

Git Repository stages can be used to push and pull snapshots from a GitHub hosted repository. The stage can be configured with
Git Repository stages can be used to push and pull snapshots from a GitHub or Azure DevOps hosted repository. The stage can be configured with
the branch to push/pull from as well as the filename to use for the snapshot.

If a filename is not configured, it will generate the filename when pushing to the repository based on the name of Instance, Device
Expand Down
16 changes: 16 additions & 0 deletions forge/db/migrations/20260318-01-EE-extend-gittoken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const { DataTypes } = require('sequelize')

module.exports = {
/**
* upgrade database
* @param {QueryInterface} context Sequelize.QueryInterface
*/
up: async (context, Sequelize) => {
await context.addColumn('GitTokens', 'type', {
type: DataTypes.STRING,
defaultValue: 'github',
allowNull: false
})
},
down: async (context, Sequelize) => { }
}
5 changes: 5 additions & 0 deletions forge/ee/db/models/GitToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ module.exports = {
token: {
type: DataTypes.STRING,
allowNull: false
},
type: {
type: DataTypes.STRING,
allowNull: false,
default: 'github'
}
},
associations: function (M) {
Expand Down
2 changes: 2 additions & 0 deletions forge/ee/db/models/PipelineStageGitRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ module.exports = {
}
await app.gitops.pushToRepository({
token: gitToken.token,
tokenType: gitToken.type,
url: this.url,
branch: this.branch,
credentialSecret: this.credentialSecret,
Expand Down Expand Up @@ -154,6 +155,7 @@ module.exports = {
}
const snapshotContent = await app.gitops.pullFromRepository({
token: gitToken.token,
tokenType: gitToken.type,
url: this.url,
branch: this.pullBranch || this.branch,
credentialSecret: this.credentialSecret,
Expand Down
3 changes: 2 additions & 1 deletion forge/ee/db/views/GitToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ module.exports = {
const result = token.toJSON()
const filtered = {
id: result.hashid,
name: result.name
name: result.name,
type: result.type
// Do not include the token value in the response
}
return filtered
Expand Down
193 changes: 193 additions & 0 deletions forge/ee/lib/gitops/backends/azure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
const { exec } = require('node:child_process')
const { existsSync } = require('node:fs')
const fs = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const { promisify } = require('node:util')
const execPromised = promisify(exec)

const axios = require('axios')

const { encryptValue, decryptValue } = require('../../../../db/utils')

const { cloneRepository } = require('./utils')

module.exports.init = async function (app) {
/**
* Push a snapshot to a git repository
* @param {Object} repoOptions
* @param {String} repoOptions.token
* @param {String} repoOptions.url
* @param {String} repoOptions.branch
* @param {Object} snapshot
* @param {Object} options
* @param {Object} options.sourceObject what produced the snapshot
* @param {Object} options.user who triggered the pipeline
* @param {Object} options.pipeline details of the pipeline
*/
async function pushToRepository (repoOptions, snapshot, options) {
let workingDir
try {
const token = repoOptions.token
const branch = repoOptions.branch || 'main'
if (!/^https:\/\/dev.azure.com/i.test(repoOptions.url)) {
throw new Error('Only Azure repositories are supported')
}
const url = new URL(repoOptions.url)
url.username = token

const match = /^https:\/\/dev.azure.com\/(?<org>.+)\/_git\/.+$/.exec(repoOptions.url)
const orgName = match.groups?.org

// 2. get user details so we can properly attribute the commit
let userDetails = {}
if (orgName) {
const userDetailsURL = `https://dev.azure.com/${orgName}/_apis/connectionData`
try {
userDetails = await axios.get(userDetailsURL, {
auth: {
username: '',
password: token
}
})
} catch (err) {
const result = new Error('Invalid git token')
result.code = 'invalid_token'
result.cause = err
throw result
}
}

const userGitName = userDetails.data?.authenticatedUser?.customDisplayName || userDetails.data?.authenticatedUser?.providerDisplayName || 'FlowFuse'
const userGitEmail = userDetails.data?.authenticatedUser?.properties?.Account.$value || `flowfuse@${app.config.domain || 'example.com'}`
const author = `${userGitName} <${userGitEmail}>`.replace(/"/g, '\\"')
workingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flowfuse-git-repo-'))

// 3. clone repo
await cloneRepository(url, branch, workingDir)

// 4. set username/email
await execPromised('git config user.email "no-reply@flowfuse.com"', { cwd: workingDir })
await execPromised('git config user.name "FlowFuse"', { cwd: workingDir })
// For local dev - disable gpg signing in case its set in global config
await execPromised('git config commit.gpgsign false', { cwd: workingDir })

// 5. export snapshot
const exportOptions = {
credentialSecret: repoOptions.credentialSecret,
components: {
flows: true,
credentials: true
}
}
const result = await app.db.controllers.Snapshot.exportSnapshot(snapshot, exportOptions)
const snapshotExport = app.db.views.ProjectSnapshot.snapshotExport(result)
if (snapshotExport.settings?.settings?.palette?.npmrc) {
const enc = encryptValue(repoOptions.credentialSecret, snapshotExport.settings.settings?.palette?.npmrc)
snapshotExport.settings.settings.palette.npmrc = { $: enc }
}
const snapshotFile = path.join(workingDir, repoOptions.path || 'snapshot.json').replace(/"/g, '')
await fs.writeFile(snapshotFile, JSON.stringify(snapshotExport, null, 4))

// 6. stage file
await execPromised(`git add "${snapshotFile}"`, { cwd: workingDir })

// 7. commit
await execPromised(`git commit -m "Update snapshot\n\nSnapshot updated by FlowFuse Pipeline '${options.pipeline.name.replace(/"/g, '')}', triggered by ${options.user.username.replace(/"/g, '')}" --author="${author}"`, { cwd: workingDir })

try {
// 8. push
await execPromised('git push', { cwd: workingDir })
} catch (err) {
const output = err.stdout + err.stderr
if (/unable to access/.test(output)) {
const result = new Error('Permission denied')
result.code = 'invalid_token'
result.cause = err
throw result
}
let error
const m = /fatal: (.*)/.exec(output)
if (m) {
error = new Error('Failed to push repository: ' + m[1])
} else {
error = Error('Failed to push repository')
}
error.cause = err
throw error
}
} finally {
if (workingDir) {
try {
await fs.rm(workingDir, { recursive: true, force: true })
} catch (err) {}
}
}
}

/**
* Push a snapshot to a git repository
* @param {Object} repoOptions
* @param {String} repoOptions.token
* @param {String} repoOptions.url
* @param {String} repoOptions.branch
*/
async function pullFromRepository (repoOptions) {
let workingDir
try {
const token = repoOptions.token
const branch = repoOptions.branch || 'main'
if (!/^https:\/\/dev.azure.com/i.test(repoOptions.url)) {
throw new Error('Only Azure repositories are supported')
}
const url = new URL(repoOptions.url)
url.username = token

workingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flowfuse-git-repo-'))

// 3. clone repo
await cloneRepository(url, branch, workingDir)

const snapshotFile = path.join(workingDir, repoOptions.path || 'snapshot.json').replace(/"/g, '')

if (!existsSync(snapshotFile)) {
throw new Error('Snapshot file not found in repository')
}

try {
const snapshotContent = await fs.readFile(snapshotFile, 'utf8')
const snapshot = JSON.parse(snapshotContent)
if (snapshot.settings?.env) {
const keys = Object.keys(snapshot.settings.env)
keys.forEach((key) => {
const env = snapshot.settings.env[key]
if (env.hidden && env.$) {
// Decrypt the value if it is encrypted
env.value = decryptValue(repoOptions.credentialSecret, env.$)
delete env.$
}
})
}
if (snapshot.settings?.settings?.palette?.npmrc) {
const npmrc = snapshot.settings.settings.palette.npmrc
if (typeof npmrc === 'object' && npmrc.$) {
snapshot.settings.settings.palette.npmrc = decryptValue(repoOptions.credentialSecret, npmrc.$)
}
}
return snapshot
} catch (err) {
throw new Error('Failed to read snapshot file: ' + err.message)
}
} finally {
if (workingDir) {
try {
await fs.rm(workingDir, { recursive: true, force: true })
} catch (err) {}
}
}
}
return {
pushToRepository,
pullFromRepository
}
}
Loading
Loading