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
4 changes: 4 additions & 0 deletions ghost/core/core/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,10 @@ async function initBackgroundServices({config}) {
const updateCheck = require('./server/services/update-check');
updateCheck.scheduleRecurringJobs();

// Remote feature-flag overrides (Pro-only; inert unless explicitly configured).
const remoteFlags = require('./server/services/remote-flags');
remoteFlags.init();

const milestonesService = require('./server/services/milestones');
milestonesService.initAndRun();

Expand Down
78 changes: 78 additions & 0 deletions ghost/core/core/server/services/remote-flags/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const config = require('../../../shared/config');
const labs = require('../../../shared/labs');
const logging = require('@tryghost/logging');
const request = require('@tryghost/request');
const RemoteFlagsService = require('./remote-flags-service');

let instance = null;

/**
* Start the remote feature-flag poller, if it is enabled for this instance.
*
* Pro-only and opt-in: the service stays completely inert unless the `remoteFlags`
* config block is explicitly enabled with a manifest `url`, on a container that has
* a `hostSettings:siteId`. Self-hosted and dev installs have neither by default, so
* this is a no-op there and labs behaves exactly as before.
*
* Polling is started fire-and-forget so boot is never blocked on (or failed by) the
* first manifest fetch; the service is fail-open and applies overrides once the
* fetch completes.
*
* @returns {RemoteFlagsService|null} the running service, or null when inert
*/
module.exports.init = function init() {
if (instance) {
return instance;
}

const remoteFlags = config.get('remoteFlags') || {};
const siteId = config.get('hostSettings:siteId');

if (remoteFlags.enabled !== true || !remoteFlags.url || siteId === undefined || siteId === null) {
return null;
}

try {
// Validate the URL once here so a misconfigured manifest url fails loudly at
// start rather than silently warning on every poll for the life of the process.
// eslint-disable-next-line no-new
new URL(remoteFlags.url);
} catch (err) {
logging.warn({
system: {event: 'remote_flags.invalid_url', siteId}
}, `Remote feature flags url is not a valid URL, not starting: ${remoteFlags.url}`);
return null;
}

instance = new RemoteFlagsService({
url: remoteFlags.url,
siteId,
getKnownFlags: () => labs.getAllFlags(),
applyOverrides: overrides => labs.setRemoteOverrides(overrides),
request
});

// Fire-and-forget: start() is fail-open and never rejects, so this neither
// blocks boot nor produces an unhandled rejection.
instance.start();

return instance;
};

/**
* Stop the poller. This only halts polling; it intentionally leaves the
* last-applied overrides in place rather than clearing them.
*/
module.exports.stop = function stop() {
if (instance) {
instance.stop();
instance = null;
}
};

/**
* @returns {RemoteFlagsService|null}
*/
module.exports.getInstance = function getInstance() {
return instance;
};
218 changes: 218 additions & 0 deletions ghost/core/core/server/services/remote-flags/remote-flags-service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
const logging = require('@tryghost/logging');
const resolve = require('./resolve');

const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
const DEFAULT_JITTER_MS = 60 * 1000; // up to 1 minute of spread per poll
const REQUEST_TIMEOUT_MS = 10 * 1000;

/**
* Polls a remote JSON manifest of feature-flag overrides, resolves it for this
* site, and pushes the result into the labs service. Designed to be unconditionally
* fail-open: a missing, broken, or unreachable manifest must never change live flag
* state in a way that takes the site down, so every failure path either keeps the
* last-known-good overrides or clears them, and nothing here ever throws out.
*
* The HTTP client, the known-flags source, and the override sink are all injected so
* the polling/caching/fail-open logic can be unit-tested without real I/O or timers.
*/
class RemoteFlagsService {
/**
* @param {object} deps
* @param {string} deps.url - canonical manifest URL (one per environment)
* @param {number|string} deps.siteId - this container's Pro site id
* @param {function(): string[]} deps.getKnownFlags - returns the overridable flag keys
* @param {function(Object<string, boolean>): void} deps.applyOverrides - sink for resolved overrides (labs.setRemoteOverrides)
* @param {function(string, object): Promise} deps.request - @tryghost/request-compatible client
* @param {number} [deps.pollInterval] - base poll interval in ms
* @param {number} [deps.jitter] - max random extra delay per poll in ms
* @param {function(): number} [deps.getRandom] - returns 0..1 (injectable for tests)
*/
constructor(deps) {
this.url = deps.url;
this.siteId = deps.siteId;
this.getKnownFlags = deps.getKnownFlags;
this.applyOverrides = deps.applyOverrides;
this.request = deps.request;
this.pollInterval = deps.pollInterval || DEFAULT_POLL_INTERVAL_MS;
this.jitter = deps.jitter === undefined ? DEFAULT_JITTER_MS : deps.jitter;
this.getRandom = deps.getRandom || Math.random;

this._etag = null; // last seen ETag, for conditional GETs
this._resolvedKey = null; // serialized last-applied resolved map, for change detection
this._timer = null;
this._started = false;
this._refreshing = false;
}

/**
* Fetch the manifest once, resolve it for this site, and apply it. Always
* fail-open; never rejects. Not re-entrant: overlapping calls are coalesced so
* concurrent refreshes can never race the cached ETag.
* @returns {Promise<void>}
*/
async refresh() {
if (this._refreshing) {
return;
}
this._refreshing = true;
try {
await this._doRefresh();
} finally {
this._refreshing = false;
}
}

/** @private */
async _doRefresh() {
let response;
try {
const headers = {};
if (this._etag) {
headers['if-none-match'] = this._etag;
}
response = await this.request(this.url, {
method: 'GET',
headers,
throwHttpErrors: false,
responseType: 'text',
followRedirect: false,
retry: {limit: 0},
timeout: {request: REQUEST_TIMEOUT_MS}
});
} catch (err) {
// Network/timeout error: keep last-known-good, change nothing.
logging.warn({
system: {event: 'remote_flags.fetch_failed', siteId: this.siteId},
err
}, 'Remote feature flags fetch failed; keeping last-known-good');
return;
}

try {
const status = response && response.statusCode;

if (status === 304) {
// Not modified: current overrides are still correct.
return;
}

if (status === 404) {
// No manifest published: no opinion for anyone, fail open to empty.
this._etag = null;
this._applyAndMaybeLog({}, null);
return;
}

if (!status || status < 200 || status >= 300) {
logging.warn({
system: {event: 'remote_flags.fetch_bad_status', siteId: this.siteId, statusCode: status || null}
}, 'Remote feature flags fetch returned an unexpected status; keeping last-known-good');
return;
}

let manifest;
try {
manifest = JSON.parse(response.body);
} catch (parseErr) {
logging.warn({
system: {event: 'remote_flags.parse_failed', siteId: this.siteId},
err: parseErr
}, 'Remote feature flags manifest was not valid JSON; keeping last-known-good');
return;
}

// got lowercases response header keys, so `.etag` is the only spelling.
const etag = (response.headers && response.headers.etag) || null;
// Commit the ETag only after the manifest has actually been applied: if
// apply throws, we keep the old ETag so the next poll re-fetches and
// retries instead of getting a 304 for a manifest we never applied.
this._applyAndMaybeLog(manifest, etag);
this._etag = etag;
} catch (err) {
// Defensive backstop: resolve()/applyOverrides should not throw, but if
// anything does, fail open rather than letting it escape the poll loop.
logging.warn({
system: {event: 'remote_flags.apply_failed', siteId: this.siteId},
err
}, 'Remote feature flags could not be applied; keeping last-known-good');
}
}

/**
* Resolve a manifest for this site, push it to the override sink, and emit a
* structured log only when the resolved set actually changes (so a steady fleet
* does not log on every poll).
* @private
*/
_applyAndMaybeLog(manifest, etag) {
const resolved = resolve(manifest, {
siteId: this.siteId,
knownFlags: this.getKnownFlags()
});

this.applyOverrides(resolved);

// Canonicalise with sorted keys so a manifest that only reorders its keys
// does not look "changed" and emit a spurious applied log on every container.
const key = JSON.stringify(resolved, Object.keys(resolved).sort());
if (key !== this._resolvedKey) {
this._resolvedKey = key;
logging.info({
system: {
event: 'remote_flags.applied',
siteId: this.siteId,
etag: etag || null,
flags: resolved
}
}, 'Remote feature flags applied');
}
}

_scheduleNext() {
if (!this._started) {
return;
}
// Ensure a single outstanding timer even across a stop/start cycle where an
// in-flight callback could otherwise schedule a second chain.
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
const delay = this.pollInterval + Math.floor(this.getRandom() * this.jitter);
this._timer = setTimeout(async () => {
await this.refresh();
this._scheduleNext();
}, delay);
// Never hold the process open just for the poll timer.
if (this._timer && typeof this._timer.unref === 'function') {
this._timer.unref();
}
}

/**
* Start polling: an immediate refresh (so boot reflects current flag state),
* then a jittered recurring poll. Idempotent.
* @returns {Promise<void>}
*/
async start() {
if (this._started) {
return;
}
this._started = true;
await this.refresh();
this._scheduleNext();
}

/**
* Stop polling. Does not clear already-applied overrides.
*/
stop() {
this._started = false;
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
}
}

module.exports = RemoteFlagsService;
Loading
Loading