From f1770e29813edf2a71aa51b61d1cae2d35fa99be Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Thu, 11 Jun 2026 12:48:15 -0700 Subject: [PATCH 01/10] Add username and caCertificate fields to GitToken with migration --- .../20260611-01-EE-extend-gittoken-generic.js | 19 +++++++++++++++++++ forge/ee/db/models/GitToken.js | 8 ++++++++ 2 files changed, 27 insertions(+) create mode 100644 forge/db/migrations/20260611-01-EE-extend-gittoken-generic.js diff --git a/forge/db/migrations/20260611-01-EE-extend-gittoken-generic.js b/forge/db/migrations/20260611-01-EE-extend-gittoken-generic.js new file mode 100644 index 0000000000..3f36aee4df --- /dev/null +++ b/forge/db/migrations/20260611-01-EE-extend-gittoken-generic.js @@ -0,0 +1,19 @@ +const { DataTypes } = require('sequelize') + +module.exports = { + /** + * upgrade database + * @param {QueryInterface} context Sequelize.QueryInterface + */ + up: async (context, Sequelize) => { + await context.addColumn('GitTokens', 'username', { + type: DataTypes.STRING, + allowNull: true + }) + await context.addColumn('GitTokens', 'caCertificate', { + type: DataTypes.TEXT, + allowNull: true + }) + }, + down: async (context, Sequelize) => { } +} diff --git a/forge/ee/db/models/GitToken.js b/forge/ee/db/models/GitToken.js index 32cc90fef0..67f1bc340c 100644 --- a/forge/ee/db/models/GitToken.js +++ b/forge/ee/db/models/GitToken.js @@ -15,6 +15,14 @@ module.exports = { type: DataTypes.STRING, allowNull: false, default: 'github' + }, + username: { + type: DataTypes.STRING, + allowNull: true + }, + caCertificate: { + type: DataTypes.TEXT, + allowNull: true } }, associations: function (M) { From d32529ccf9c3c4335d5a79fc14cf9b6af95068c7 Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Thu, 11 Jun 2026 13:07:18 -0700 Subject: [PATCH 02/10] Add generic Git backend for arbitrary HTTPS servers --- forge/ee/lib/gitops/backends/generic.js | 168 ++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 forge/ee/lib/gitops/backends/generic.js diff --git a/forge/ee/lib/gitops/backends/generic.js b/forge/ee/lib/gitops/backends/generic.js new file mode 100644 index 0000000000..d1552c0642 --- /dev/null +++ b/forge/ee/lib/gitops/backends/generic.js @@ -0,0 +1,168 @@ +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 { 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 {String} repoOptions.username + * @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 branch = repoOptions.branch || 'main' + if (!/^https:\/\//i.test(repoOptions.url)) { + throw new Error('Only HTTPS git URLs are supported') + } + const url = new URL(repoOptions.url) + url.username = repoOptions.username ? repoOptions.username : 'x-access-token' + url.password = repoOptions.token + + 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, '')}"`, { 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 + * @param {String} repoOptions.username + */ + async function pullFromRepository (repoOptions) { + let workingDir + try { + const branch = repoOptions.branch || 'main' + if (!/^https:\/\//i.test(repoOptions.url)) { + throw new Error('Only HTTPS git URLs are supported') + } + const url = new URL(repoOptions.url) + url.username = repoOptions.username ? repoOptions.username : 'x-access-token' + url.password = repoOptions.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 + } +} From c99cc4378e41604f9e720722068e87645d8925c0 Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Thu, 11 Jun 2026 13:32:08 -0700 Subject: [PATCH 03/10] Wire generic Git provider through dispatch and UI --- forge/ee/db/models/PipelineStageGitRepo.js | 2 ++ forge/ee/lib/gitops/index.js | 5 +++++ forge/ee/routes/gitops/index.js | 3 ++- frontend/src/assets/icons/git.svg | 7 +++++++ .../pages/application/PipelineStage/form.vue | 21 +++++++++---------- .../Settings/dialogs/CreateGitTokenDialog.vue | 16 ++++++++++++-- .../GenericInstructions.vue | 17 +++++++++++++++ 7 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 frontend/src/assets/icons/git.svg create mode 100644 frontend/src/pages/team/Settings/dialogs/components/CreateGitTokenDialog/GenericInstructions.vue diff --git a/forge/ee/db/models/PipelineStageGitRepo.js b/forge/ee/db/models/PipelineStageGitRepo.js index 21453fc974..c2696bbabf 100644 --- a/forge/ee/db/models/PipelineStageGitRepo.js +++ b/forge/ee/db/models/PipelineStageGitRepo.js @@ -103,6 +103,7 @@ module.exports = { await app.gitops.pushToRepository({ token: gitToken.token, tokenType: gitToken.type, + username: gitToken.username, url: this.url, branch: this.branch, credentialSecret: this.credentialSecret, @@ -156,6 +157,7 @@ module.exports = { const snapshotContent = await app.gitops.pullFromRepository({ token: gitToken.token, tokenType: gitToken.type, + username: gitToken.username, url: this.url, branch: this.pullBranch || this.branch, credentialSecret: this.credentialSecret, diff --git a/forge/ee/lib/gitops/index.js b/forge/ee/lib/gitops/index.js index 1850221528..a8789ec1a3 100644 --- a/forge/ee/lib/gitops/index.js +++ b/forge/ee/lib/gitops/index.js @@ -15,6 +15,7 @@ module.exports.init = async function (app) { // load backends const github = await require('./backends/github').init(app) const azure = await require('./backends/azure').init(app) + const generic = await require('./backends/generic').init(app) // Set the git feature flag app.config.features.register('gitIntegration', true, true) @@ -36,6 +37,8 @@ module.exports.init = async function (app) { await github.pushToRepository(repoOptions, snapshot, options) } else if (repoOptions.tokenType === 'azure') { await azure.pushToRepository(repoOptions, snapshot, options) + } else if (repoOptions.tokenType === 'generic') { + await generic.pushToRepository(repoOptions, snapshot, options) } } @@ -51,6 +54,8 @@ module.exports.init = async function (app) { return github.pullFromRepository(repoOptions) } else if (repoOptions.tokenType === 'azure') { return azure.pullFromRepository(repoOptions) + } else if (repoOptions.tokenType === 'generic') { + return generic.pullFromRepository(repoOptions) } } diff --git a/forge/ee/routes/gitops/index.js b/forge/ee/routes/gitops/index.js index 931047384f..69869d770d 100644 --- a/forge/ee/routes/gitops/index.js +++ b/forge/ee/routes/gitops/index.js @@ -49,7 +49,8 @@ module.exports = async function (app) { name: body.name, token: body.token, TeamId: request.team.id, - type: body.type || 'github' + type: body.type || 'github', + username: body.username || '' }) // TODO: audit log // await app.auditLog.Project.project.httpToken.created(request.session.User, null, request.project, body) diff --git a/frontend/src/assets/icons/git.svg b/frontend/src/assets/icons/git.svg new file mode 100644 index 0000000000..f87f253478 --- /dev/null +++ b/frontend/src/assets/icons/git.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/pages/application/PipelineStage/form.vue b/frontend/src/pages/application/PipelineStage/form.vue index 4d81102feb..6507b93c41 100644 --- a/frontend/src/pages/application/PipelineStage/form.vue +++ b/frontend/src/pages/application/PipelineStage/form.vue @@ -659,18 +659,15 @@ export default { repoStageHasCredentialSecret () { return this.stage.gitRepo?.credentialSecret }, + selectedGitTokenType () { + const tok = this.gitTokens.find(t => t.value === this.input.gitTokenId) + return tok?.type + }, gitPlaceholder () { - if (this.input.gitTokenId) { - for (const i in this.gitTokens) { - const tok = this.gitTokens[i] - if (tok.value === this.input.gitTokenId) { - if (tok.type === 'github') { - return 'e.g. https://github.com/[org]/[repo]' - } else if (tok.type === 'azure') { - return 'e.g. https://dev.azure.com/[org]/_git/[repo]' - } - } - } + if (this.selectedGitTokenType === 'azure') { + return 'e.g. https://dev.azure.com/[org]/_git/[repo]' + } else if (this.selectedGitTokenType === 'generic') { + return 'e.g. https://git.example.com/org/repo.git' } return 'e.g. https://github.com/[org]/[repo]' } @@ -688,6 +685,8 @@ export default { 'input.url' (newUrl, oldUrl) { if (newUrl === '') { this.errors.url = '' + } else if (this.selectedGitTokenType === 'generic') { + this.errors.url = /^https:\/\//i.test(newUrl) ? '' : 'Please enter a valid HTTPS repository URL' } else if (!/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(newUrl) && !/^https:\/\/dev\.azure\.com\/[^/]+\/_git\/[^/]+$/.test(newUrl)) { this.errors.url = 'Please enter a valid GitHub or Azure DevOps repository URL' } else { diff --git a/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue b/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue index e989214139..9ae9ff40eb 100644 --- a/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue +++ b/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue @@ -12,6 +12,7 @@
Name Token + Username
@@ -21,11 +22,13 @@ import { markRaw } from 'vue' import AzureInstructions from './components/CreateGitTokenDialog/AzureInstructions.vue' +import GenericInstructions from './components/CreateGitTokenDialog/GenericInstructions.vue' import GitHubInstructions from './components/CreateGitTokenDialog/GitHubInstructions.vue' import teamApi from '@/api/team.js' import AzureIcon from '@/assets/icons/azure.svg' +import GitIcon from '@/assets/icons/git.svg' import GitHubIcon from '@/assets/icons/github.svg' import FormRow from '@/components/FormRow.vue' import { CascadingSelector, OptionTileSelector } from '@/components/variant-selector/index.js' @@ -51,6 +54,7 @@ export default { this.input.name = '' this.input.token = '' this.input.type = 'github' + this.input.username = '' this.$refs.dialog.show() } } @@ -60,7 +64,8 @@ export default { input: { name: '', token: '', - type: 'github' + type: 'github', + username: '' }, errors: {}, providerTree: { @@ -77,6 +82,11 @@ export default { id: 'azure', component: markRaw(AzureInstructions), props: { label: 'Azure DevOps', icon: AzureIcon } + }, + { + id: 'generic', + component: markRaw(GenericInstructions), + props: { label: 'Other / Self-hosted', icon: GitIcon } } ] } @@ -92,6 +102,7 @@ export default { 'input.type' () { this.input.name = '' this.input.token = '' + this.input.username = '' this.errors = {} } }, @@ -101,7 +112,8 @@ export default { name: this.input.name.trim(), token: this.input.token, team: this.team.id, - type: this.input.type + type: this.input.type, + username: this.input.username } this.$emit('token-creating') teamApi.createGitToken(opts.team, opts).then((response) => { diff --git a/frontend/src/pages/team/Settings/dialogs/components/CreateGitTokenDialog/GenericInstructions.vue b/frontend/src/pages/team/Settings/dialogs/components/CreateGitTokenDialog/GenericInstructions.vue new file mode 100644 index 0000000000..7296e38387 --- /dev/null +++ b/frontend/src/pages/team/Settings/dialogs/components/CreateGitTokenDialog/GenericInstructions.vue @@ -0,0 +1,17 @@ + + + From b84fcdfa71f7f25face27552d29d754d7858133a Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Fri, 26 Jun 2026 11:17:33 -0700 Subject: [PATCH 04/10] Wire generic Git provider, fix types, fix vue warn for type prop --- forge/ee/db/views/PipelineStage.js | 8 ++++---- frontend/src/components/icons/Git.js | 5 ++--- .../pages/team/Settings/dialogs/CreateGitTokenDialog.vue | 4 ++-- frontend/src/types/generated.ts | 8 ++++---- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/forge/ee/db/views/PipelineStage.js b/forge/ee/db/views/PipelineStage.js index 4f746e80ec..7722e98784 100644 --- a/forge/ee/db/views/PipelineStage.js +++ b/forge/ee/db/views/PipelineStage.js @@ -28,10 +28,10 @@ module.exports = function (app) { pullBranch: { type: 'string' }, pushPath: { type: 'string' }, pullPath: { type: 'string' }, - lastPushAt: { type: 'string' }, - lastPullAt: { type: 'string' }, - status: { type: 'string' }, - statusMessage: { type: 'string' }, + lastPushAt: { type: 'string', nullable: true }, + lastPullAt: { type: 'string', nullable: true }, + status: { type: 'string', nullable: true }, + statusMessage: { type: 'string', nullable: true }, credentialSecret: { type: 'boolean' } }, required: ['gitTokenId', 'url', 'branch', 'pullBranch', 'pushPath', 'pullPath', 'lastPushAt', 'lastPullAt', 'status', 'statusMessage', 'credentialSecret'], diff --git a/frontend/src/components/icons/Git.js b/frontend/src/components/icons/Git.js index 393e110cfb..b93c5a9121 100644 --- a/frontend/src/components/icons/Git.js +++ b/frontend/src/components/icons/Git.js @@ -3,10 +3,9 @@ const { createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBl module.exports = { props: { type: { - required: true, - type: String + type: String, + default: '' } - }, render: function (_ctx, _cache) { const path1 = _createVNode('path', { diff --git a/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue b/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue index 8c9de16343..599d890fdc 100644 --- a/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue +++ b/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue @@ -71,7 +71,7 @@ export default { providerTree: { id: 'root', component: markRaw(OptionTileSelector), - props: { columns: 2 }, + props: { columns: 3 }, children: [ { id: 'github', @@ -86,7 +86,7 @@ export default { { id: 'generic', component: markRaw(GenericInstructions), - props: { label: 'Other / Self-hosted', icon: markRaw(GitIcon) } + props: { label: 'Other', icon: markRaw(GitIcon) } } ] } diff --git a/frontend/src/types/generated.ts b/frontend/src/types/generated.ts index 86625dec01..322e7eed80 100644 --- a/frontend/src/types/generated.ts +++ b/frontend/src/types/generated.ts @@ -10939,10 +10939,10 @@ export interface components { pullBranch: string; pushPath: string; pullPath: string; - lastPushAt: string; - lastPullAt: string; - status: string; - statusMessage: string; + lastPushAt: string | null; + lastPullAt: string | null; + status: string | null; + statusMessage: string | null; credentialSecret: boolean; }; /** @enum {string} */ From 9fc82474603e082c83b68288b9094769965a4304 Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Fri, 26 Jun 2026 11:40:32 -0700 Subject: [PATCH 05/10] Setup for CA certs --- forge/ee/db/models/PipelineStageGitRepo.js | 2 + forge/ee/lib/gitops/backends/generic.js | 42 +++++++++++++++---- forge/ee/lib/gitops/backends/utils.js | 4 +- forge/ee/routes/gitops/index.js | 3 +- .../Settings/dialogs/CreateGitTokenDialog.vue | 13 +++++- 5 files changed, 51 insertions(+), 13 deletions(-) diff --git a/forge/ee/db/models/PipelineStageGitRepo.js b/forge/ee/db/models/PipelineStageGitRepo.js index c2696bbabf..df40da2e1b 100644 --- a/forge/ee/db/models/PipelineStageGitRepo.js +++ b/forge/ee/db/models/PipelineStageGitRepo.js @@ -104,6 +104,7 @@ module.exports = { token: gitToken.token, tokenType: gitToken.type, username: gitToken.username, + caCertificate: gitToken.caCertificate, url: this.url, branch: this.branch, credentialSecret: this.credentialSecret, @@ -158,6 +159,7 @@ module.exports = { token: gitToken.token, tokenType: gitToken.type, username: gitToken.username, + caCertificate: gitToken.caCertificate, url: this.url, branch: this.pullBranch || this.branch, credentialSecret: this.credentialSecret, diff --git a/forge/ee/lib/gitops/backends/generic.js b/forge/ee/lib/gitops/backends/generic.js index d1552c0642..0c8289db78 100644 --- a/forge/ee/lib/gitops/backends/generic.js +++ b/forge/ee/lib/gitops/backends/generic.js @@ -26,6 +26,7 @@ module.exports.init = async function (app) { */ async function pushToRepository (repoOptions, snapshot, options) { let workingDir + let caFile try { const branch = repoOptions.branch || 'main' if (!/^https:\/\//i.test(repoOptions.url)) { @@ -37,14 +38,21 @@ module.exports.init = async function (app) { workingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flowfuse-git-repo-')) + let gitEnv = process.env + if (repoOptions.caCertificate) { + caFile = `${workingDir}-ca.pem` + await fs.writeFile(caFile, repoOptions.caCertificate) + gitEnv = { ...process.env, GIT_SSL_CAINFO: caFile } + } + // 3. clone repo - await cloneRepository(url, branch, workingDir) + await cloneRepository(url, branch, workingDir, gitEnv) // 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 }) + await execPromised('git config user.email "no-reply@flowfuse.com"', { cwd: workingDir, env: gitEnv }) + await execPromised('git config user.name "FlowFuse"', { cwd: workingDir, env: gitEnv }) // For local dev - disable gpg signing in case its set in global config - await execPromised('git config commit.gpgsign false', { cwd: workingDir }) + await execPromised('git config commit.gpgsign false', { cwd: workingDir, env: gitEnv }) // 5. export snapshot const exportOptions = { @@ -64,14 +72,14 @@ module.exports.init = async function (app) { await fs.writeFile(snapshotFile, JSON.stringify(snapshotExport, null, 4)) // 6. stage file - await execPromised(`git add "${snapshotFile}"`, { cwd: workingDir }) + await execPromised(`git add "${snapshotFile}"`, { cwd: workingDir, env: gitEnv }) // 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, '')}"`, { cwd: workingDir }) + 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, '')}"`, { cwd: workingDir, env: gitEnv }) try { // 8. push - await execPromised('git push', { cwd: workingDir }) + await execPromised('git push', { cwd: workingDir, env: gitEnv }) } catch (err) { const output = err.stdout + err.stderr if (/unable to access/.test(output)) { @@ -96,6 +104,11 @@ module.exports.init = async function (app) { await fs.rm(workingDir, { recursive: true, force: true }) } catch (err) {} } + if (caFile) { + try { + await fs.rm(caFile, { force: true }) + } catch (err) {} + } } } @@ -109,6 +122,7 @@ module.exports.init = async function (app) { */ async function pullFromRepository (repoOptions) { let workingDir + let caFile try { const branch = repoOptions.branch || 'main' if (!/^https:\/\//i.test(repoOptions.url)) { @@ -120,8 +134,15 @@ module.exports.init = async function (app) { workingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flowfuse-git-repo-')) + let gitEnv = process.env + if (repoOptions.caCertificate) { + caFile = `${workingDir}-ca.pem` + await fs.writeFile(caFile, repoOptions.caCertificate) + gitEnv = { ...process.env, GIT_SSL_CAINFO: caFile } + } + // 3. clone repo - await cloneRepository(url, branch, workingDir) + await cloneRepository(url, branch, workingDir, gitEnv) const snapshotFile = path.join(workingDir, repoOptions.path || 'snapshot.json').replace(/"/g, '') @@ -159,6 +180,11 @@ module.exports.init = async function (app) { await fs.rm(workingDir, { recursive: true, force: true }) } catch (err) {} } + if (caFile) { + try { + await fs.rm(caFile, { force: true }) + } catch (err) {} + } } } return { diff --git a/forge/ee/lib/gitops/backends/utils.js b/forge/ee/lib/gitops/backends/utils.js index 7b069317f5..b8bffd3d91 100644 --- a/forge/ee/lib/gitops/backends/utils.js +++ b/forge/ee/lib/gitops/backends/utils.js @@ -2,9 +2,9 @@ const { exec } = require('node:child_process') const { promisify } = require('node:util') const execPromised = promisify(exec) -async function cloneRepository (url, branch, workingDir) { +async function cloneRepository (url, branch, workingDir, env) { try { - await execPromised(`git clone -b ${branch} --depth 1 --single-branch ${url.toString()} .`, { cwd: workingDir }) + await execPromised(`git clone -b ${branch} --depth 1 --single-branch ${url.toString()} .`, { cwd: workingDir, env }) } catch (err) { const output = err.stdout + err.stderr // Token does not have access to clone the repo diff --git a/forge/ee/routes/gitops/index.js b/forge/ee/routes/gitops/index.js index 69869d770d..a1b69c611b 100644 --- a/forge/ee/routes/gitops/index.js +++ b/forge/ee/routes/gitops/index.js @@ -50,7 +50,8 @@ module.exports = async function (app) { token: body.token, TeamId: request.team.id, type: body.type || 'github', - username: body.username || '' + username: body.username || '', + caCertificate: body.caCertificate || '' }) // TODO: audit log // await app.auditLog.Project.project.httpToken.created(request.session.User, null, request.project, body) diff --git a/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue b/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue index 599d890fdc..89d43fd442 100644 --- a/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue +++ b/frontend/src/pages/team/Settings/dialogs/CreateGitTokenDialog.vue @@ -13,6 +13,11 @@ Name Token Username + + CA Certificate (optional) + +