Skip to content
Open
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
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
190 changes: 190 additions & 0 deletions forge/ee/lib/gitops/backends/azure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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.password = token

// TODO find an azure version
Copy link
Contributor Author

@hardillb hardillb Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still needs looking at, I can't find an equivalent API that converts token to user details.

// 2. get user details so we can properly attribute the commit
// let userDetails
// try {
// userDetails = await axios.get('https://api.github.com/user', {
// headers: {
// Accept: 'application/vnd.github+json',
// Authorization: `Bearer ${token}`,
// 'X-GitHub-Api-Version': '2022-11-28'
// }
// })
// } catch (err) {
// const result = new Error('Invalid git token')
// result.code = 'invalid_token'
// result.cause = err
// throw result
// }

// TODO fix these place holders
const userGitName = 'flowfuse' // userDetails.data.login
const userGitEmail = 'flowfuse@example.com' // `${userDetails.data.id}+${userDetails.data.login}@users.noreply.github.com`
const author = `${userGitName} <${userGitEmail}>`.replace(/"/g, '\\"')
Comment on lines +57 to +60
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

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.password = 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