Skip to content
Draft
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
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,27 @@

# Uncomment this to get GitHub comments for the Pull Request Workflow.
# ENABLE_PR_COMMENT=true

# ADMIN_REPO=safe-settings-config
CONFIG_PATH=.github
SETTINGS_FILE_PATH=settings.yml

# Configuration support for Hub-Sync safe-settings feature
# SAFE_SETTINGS_HUB_REPO=safe-settings-config-master
# SAFE_SETTINGS_HUB_ORG=foo-training
# A subfolder under 'CONFIG_PATH' where the 'organizations/<org>/<repo>' structure is found
# SAFE_SETTINGS_HUB_PATH=safe-settings
# SAFE_SETTINGS_HUB_DIRECT_PUSH=true



# ┌────────────── second (optional)
# │ ┌──────────── minute
# │ │ ┌────────── hour
# │ │ │ ┌──────── day of month
# │ │ │ │ ┌────── month
# │ │ │ │ │ ┌──── day of week
# │ │ │ │ │ │
# │ │ │ │ │ │
# * * * * * *
# CRON=* * * * * # Run every minute
34 changes: 34 additions & 0 deletions docs/hubSyncHandler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Safe Settings Organization Sync & Dashboard

This feature provides a centralized approach to managing the Safe-Settings Admin Repo, allowing Safe-Settings configurations to be sync'd across multiple ORGs.

## Overview

This feature adds a hub‑and‑spoke synchronization capability to Safe Settings.

One central **master admin repository** (the hub) serves as the authoritative source of configuration which is automatically propagated to each organization’s **admin repository** (the spokes).

**Note:** When something changes in the central repo, only those changed files are copied to each affected ORG’s admin repo, so everything stays in sync with little manual work.

## Sync Lifecycle (High Level)

```mermaid
graph TD
A0(PR Closed) --> A1(HUB Admin Repo)
A1(ORG Admin Repo) --> B(ORG Admin Repo)
A1(HUB Admin Repo) --> C(ORG Admin Repo)
A1(HUB Admin Repo) --> D(ORG Admin Repo)
```

## Environment Variables & Inputs

Environment variables specific to the 'Sync-Feature'

| Name | Purpose | Default |
|------|---------|---------|
| `SAFE_SETTINGS_HUB_REPO` | Repo for master safe-settings contents | admin-master |
| `SAFE_SETTINGS_HUB_ORG` | Organization that hold the Repo | admin-master-org |
| `SAFE_SETTINGS_HUB_PATH` | source folder | .github/safe-settings |
| `SAFE_SETTINGS_HUB_DIRECT_PUSH` | Use a PR or direct commit | false |


115 changes: 30 additions & 85 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,91 +6,22 @@ const Glob = require('./lib/glob')
const ConfigManager = require('./lib/configManager')
const NopCommand = require('./lib/nopcommand')
const env = require('./lib/env')
const { setupRoutes } = require('./lib/routes')
const { initCache } = require('./lib/installationCache')
const { hubSyncHandler } = require('./lib/hubSyncHandler')

let deploymentConfig

module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => {
let appSlug = 'safe-settings'
async function syncAllSettings (nop, context, repo = context.repo(), ref) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
const configManager = new ConfigManager(context, ref)
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
if (ref) {
return Settings.syncAll(nop, context, repo, config, ref)
} else {
return Settings.syncAll(nop, context, repo, config)
}
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
if (!deploymentConfig) {
filename = env.DEPLOYMENT_CONFIG_FILE_PATH
deploymentConfig = {}
}
const nopcommand = new NopCommand(filename, repo, null, e, 'ERROR')
robot.log.error(`NOPCOMMAND ${JSON.stringify(nopcommand)}`)
Settings.handleError(nop, context, repo, deploymentConfig, ref, nopcommand)
} else {
throw e
}
}
}

async function syncSubOrgSettings (nop, context, suborg, repo = context.repo(), ref) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
const configManager = new ConfigManager(context, ref)
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
return Settings.syncSubOrgs(nop, context, suborg, repo, config, ref)
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
if (!deploymentConfig) {
filename = env.DEPLOYMENT_CONFIG_FILE_PATH
deploymentConfig = {}
}
const nopcommand = new NopCommand(filename, repo, null, e, 'ERROR')
robot.log.error(`NOPCOMMAND ${JSON.stringify(nopcommand)}`)
Settings.handleError(nop, context, repo, deploymentConfig, ref, nopcommand)
} else {
throw e
}
}
}
// Initialize all routes (static UI + API) via centralized module
setupRoutes(robot, getRouter)

async function syncSettings (nop, context, repo = context.repo(), ref) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
const configManager = new ConfigManager(context, ref)
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
return Settings.sync(nop, context, repo, config, ref)
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
if (!deploymentConfig) {
filename = env.DEPLOYMENT_CONFIG_FILE_PATH
deploymentConfig = {}
}
const nopcommand = new NopCommand(filename, repo, null, e, 'ERROR')
robot.log.error(`NOPCOMMAND ${JSON.stringify(nopcommand)}`)
Settings.handleError(nop, context, repo, deploymentConfig, ref, nopcommand)
} else {
throw e
}
}
}
// Initialize installation cache (env-controlled prefetch)
initCache(robot)

async function renameSync (nop, context, repo = context.repo(), rename, ref) {
async function renameSync(nop, context, repo = context.repo(), rename, ref) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
Expand All @@ -115,13 +46,14 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}
}

/**
* Loads the deployment config file from file system
* Do this once when the app starts and then return the cached value
*
* @return The parsed YAML file
*/
async function loadYamlFileSystem () {
async function loadYamlFileSystem() {
if (deploymentConfig === undefined) {
const deploymentConfigPath = env.DEPLOYMENT_CONFIG_FILE_PATH
if (fs.existsSync(deploymentConfigPath)) {
Expand All @@ -133,7 +65,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return deploymentConfig
}

function getAllChangedSubOrgConfigs (payload) {
function getAllChangedSubOrgConfigs(payload) {
const pattern = Settings.SUB_ORG_PATTERN

const getMatchingFiles = (commits, type) =>
Expand All @@ -150,7 +82,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}))
}

function getAllChangedRepoConfigs (payload, owner) {
function getAllChangedRepoConfigs(payload, owner) {
const pattern = Settings.REPO_PATTERN

const getMatchingFiles = (commits, type) =>
Expand All @@ -167,7 +99,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}))
}

function getChangedRepoConfigName (files, owner) {
function getChangedRepoConfigName(files, owner) {
const pattern = Settings.REPO_PATTERN

const modifiedFiles = files.filter((s) => pattern.test(s))
Expand All @@ -178,7 +110,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}))
}

function getChangedSubOrgConfigName (files) {
function getChangedSubOrgConfigName(files) {
const pattern = Settings.SUB_ORG_PATTERN

const modifiedFiles = files.filter((s) => pattern.test(s))
Expand All @@ -188,7 +120,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
path: modifiedFile
}))
}
async function createCheckRun (context, pull_request, head_sha, head_branch) {
async function createCheckRun(context, pull_request, head_sha, head_branch) {
const { payload } = context
// robot.log.debug(`Check suite was requested! for ${context.repo()} ${pull_request.number} ${head_sha} ${head_branch}`)
const res = await context.octokit.checks.create({
Expand All @@ -200,7 +132,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
robot.log.debug(JSON.stringify(res, null))
}

async function info () {
async function info() {
const github = await robot.auth()
const installations = await github.paginate(
github.apps.listInstallations.endpoint.merge({ per_page: 100 })
Expand All @@ -215,7 +147,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}

async function syncInstallation (nop = false) {
async function syncInstallation(nop = false) {
robot.log.trace('Fetching installations')
const github = await robot.auth()

Expand Down Expand Up @@ -521,6 +453,19 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return createCheckRun(context, pull_request, payload.pull_request.head.sha, payload.pull_request.head.ref)
})

/**
* @description Handle pull_request.closed events to support hub synchronization
* @param {Object} context - The context object provided by Probot
*/
robot.on('pull_request.closed', async context => {
try {
await hubSyncHandler(robot, context)
} catch (err) {
robot.log.error(`pull_request.closed handler failed: ${err && err.message ? err.message : err}`)
}
return null
})

robot.on(['check_suite.rerequested'], async context => {
robot.log.debug('Check suite was rerequested!')
return createCheckRun(context)
Expand Down
6 changes: 6 additions & 0 deletions lib/env.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
module.exports = {
ADMIN_REPO: process.env.ADMIN_REPO || 'admin',
SAFE_SETTINGS_HUB_REPO: process.env.SAFE_SETTINGS_HUB_REPO || 'admin-master',
SAFE_SETTINGS_HUB_ORG: process.env.SAFE_SETTINGS_HUB_ORG || 'admin-master-org',
SAFE_SETTINGS_HUB_DIRECT_PUSH: process.env.SAFE_SETTINGS_HUB_DIRECT_PUSH || 'false',
SAFE_SETTINGS_HUB_PATH: process.env.SAFE_SETTINGS_HUB_PATH || '.github/safe-settings',
APP_ID: process.env.APP_ID || null,
PRIVATE_KEY_PATH: process.env.PRIVATE_KEY_PATH || 'private-key.pem',
CONFIG_PATH: process.env.CONFIG_PATH || '.github',
SETTINGS_FILE_PATH: process.env.SETTINGS_FILE_PATH || 'settings.yml',
DEPLOYMENT_CONFIG_FILE_PATH: process.env.DEPLOYMENT_CONFIG_FILE || 'deployment-settings.yml',
Expand Down
Loading