canopycms 0.0.68-int.111 → 0.0.68-int.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/branch-health.js +14 -9
- package/dist/cli/cli.js +4 -0
- package/dist/cli/generate-ai-content.js +2 -0
- package/dist/editor/Editor.js +2 -2
- package/dist/editor/components/StagedChangesDrawer.d.ts +37 -0
- package/dist/editor/components/StagedChangesDrawer.js +75 -0
- package/dist/editor/group-manager/GroupCard.d.ts +2 -0
- package/dist/editor/group-manager/GroupCard.js +2 -2
- package/dist/editor/group-manager/GroupForm.js +1 -1
- package/dist/editor/group-manager/InternalGroupsTab.d.ts +1 -0
- package/dist/editor/group-manager/InternalGroupsTab.js +2 -2
- package/dist/editor/group-manager/hooks/useGroupState.d.ts +2 -0
- package/dist/editor/group-manager/hooks/useGroupState.js +18 -1
- package/dist/editor/group-manager/index.js +9 -4
- package/dist/editor/group-manager/types.d.ts +3 -1
- package/dist/editor/hooks/usePermissionManager.js +4 -3
- package/dist/editor/permission-manager/index.js +9 -3
- package/dist/editor/permission-manager/types.d.ts +3 -1
- package/dist/git-manager.d.ts +10 -0
- package/dist/git-manager.js +6 -5
- package/dist/utils/error.js +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +39 -124
- package/dist/worker/cms-worker.js +232 -259
- package/dist/worker/git-sync.d.ts +7 -5
- package/dist/worker/git-sync.js +32 -29
- package/dist/worker/github-auth.d.ts +7 -7
- package/dist/worker/github-auth.js +12 -14
- package/dist/worker/github-gateway.d.ts +144 -0
- package/dist/worker/github-gateway.js +370 -0
- package/dist/worker/github-mirror.d.ts +84 -10
- package/dist/worker/github-mirror.js +237 -21
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +1 -6
- package/dist/worker/shared-repo-git.d.ts +6 -0
- package/dist/worker/shared-repo-git.js +8 -0
- package/dist/worker/task-runner.d.ts +2 -2
- package/dist/worker/task-runner.js +77 -127
- package/dist/worker/worker-context.d.ts +12 -40
- package/package.json +1 -1
|
@@ -4,18 +4,17 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import lockfile from 'proper-lockfile';
|
|
6
6
|
import { recoverOrphanedTasks, cmsTaskQueueLogger } from '../task-queue/cms-task-queue.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { ensureRemoteGitConfig } from '../git-manager.js';
|
|
7
|
+
import { resolveWorkerGitHubAuth } from './github-auth.js';
|
|
8
|
+
import { createLocalGitHubGateway, } from './github-gateway.js';
|
|
9
|
+
import { GITHUB_TRACKING_REF_PREFIX, ensureRemoteGitConfig, failOnSignalExit } from '../git-manager.js';
|
|
10
10
|
import { readHeadBranch } from '../utils/git.js';
|
|
11
|
-
import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
|
|
11
|
+
import { isSettingsBranchName, sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, } from '../paths/branch-name.js';
|
|
12
12
|
import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
|
|
13
13
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
14
14
|
import { readCarriedOverStatus, readWorkerStatusStartedAt, writeWorkerStatus, } from '../task-queue/worker-status.js';
|
|
15
15
|
import { CANOPYCMS_VERSION } from '../version.js';
|
|
16
16
|
import { workerLog, workerLogWarn, workerLogError } from './log.js';
|
|
17
17
|
import { DEFAULT_SCHEMA_HOLD_MAX_MS, readCarriedBaseHold } from './schema-gate.js';
|
|
18
|
-
import { GitHubMirror } from './github-mirror.js';
|
|
19
18
|
import { SharedRepoRefusalError, UntrustedRepoConfigError, assertSharedRepoConfig, sharedRepoGit, } from './shared-repo-git.js';
|
|
20
19
|
import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
|
|
21
20
|
import { pollMergeState, runRebaseCycle } from './rebase.js';
|
|
@@ -33,28 +32,49 @@ export { workerLog, workerLogWarn, workerLogError, installWorkerLogger } from '.
|
|
|
33
32
|
// see github-auth.ts) and needs the shape to inject and the key normalizer to
|
|
34
33
|
// apply.
|
|
35
34
|
export { normalizeGitHubAppPrivateKey, DEFAULT_GIT_TOKEN_MINT_TIMEOUT_MS, DEFAULT_GITHUB_TOKEN_REFRESH_MIN_INTERVAL_MS, } from './github-auth.js';
|
|
36
|
-
/** `target` with symlinks resolved as far as it exists, so a link cannot hide where it lands. */
|
|
37
|
-
async function realpathOfNearest(target) {
|
|
38
|
-
const missing = [];
|
|
39
|
-
let current = path.resolve(target);
|
|
40
|
-
for (;;) {
|
|
41
|
-
try {
|
|
42
|
-
return path.join(await fs.realpath(current), ...missing.reverse());
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
const parent = path.dirname(current);
|
|
46
|
-
if (parent === current)
|
|
47
|
-
return path.resolve(target);
|
|
48
|
-
missing.push(path.basename(current));
|
|
49
|
-
current = parent;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
35
|
/** Per workspace, so two workers on one host (tests, dev) never share a mirror. */
|
|
54
36
|
function defaultStateDirectory(workspacePath) {
|
|
55
37
|
const key = createHash('sha256').update(path.resolve(workspacePath)).digest('hex').slice(0, 16);
|
|
56
38
|
return path.join(os.tmpdir(), `canopycms-worker-${key}`);
|
|
57
39
|
}
|
|
40
|
+
/** How many refs a refusal to replace remote.git names before summarising the rest. */
|
|
41
|
+
const MAX_REFS_LISTED = 20;
|
|
42
|
+
/** A refusal to replace a poisoned remote.git because it holds work GitHub does not have. */
|
|
43
|
+
class RemoteGitKeptError extends Error {
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Every ref in the bare repo at `gitDir` outside the GitHub tracking namespace, which only ever
|
|
47
|
+
* holds GitHub's own branches, mapped to the object it names. A ref missing from this list is one
|
|
48
|
+
* a replacement would discard unchecked, so it throws when git reports anything on stderr
|
|
49
|
+
* (`for-each-ref` skips a ref it cannot read with only a warning and exit 0) and when git is killed
|
|
50
|
+
* by a signal, which simple-git otherwise resolves as empty output. A dangling symbolic ref is
|
|
51
|
+
* skipped without a warning, and names nothing to lose.
|
|
52
|
+
*/
|
|
53
|
+
async function listRemoteGitRefs(gitDir) {
|
|
54
|
+
let stderr = '';
|
|
55
|
+
const output = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit })
|
|
56
|
+
.outputHandler((_command, _stdout, err) => {
|
|
57
|
+
err.on('data', (chunk) => {
|
|
58
|
+
stderr += String(chunk);
|
|
59
|
+
});
|
|
60
|
+
})
|
|
61
|
+
.raw(['for-each-ref', '--format=%(objectname) %(refname)']);
|
|
62
|
+
if (stderr.trim() !== '')
|
|
63
|
+
throw new Error(stderr.trim());
|
|
64
|
+
const refs = new Map();
|
|
65
|
+
for (const line of output.split('\n')) {
|
|
66
|
+
const space = line.indexOf(' ');
|
|
67
|
+
if (space === -1)
|
|
68
|
+
continue;
|
|
69
|
+
const ref = line.slice(space + 1);
|
|
70
|
+
if (!ref.startsWith(GITHUB_TRACKING_REF_PREFIX))
|
|
71
|
+
refs.set(ref, line.slice(0, space));
|
|
72
|
+
}
|
|
73
|
+
return refs;
|
|
74
|
+
}
|
|
75
|
+
function sameRefs(a, b) {
|
|
76
|
+
return a.size === b.size && [...a].every(([ref, id]) => b.get(ref) === id);
|
|
77
|
+
}
|
|
58
78
|
const DEFAULT_TASK_TIMEOUT = 60_000;
|
|
59
79
|
const DEFAULT_MAX_RETRIES = 3;
|
|
60
80
|
const DEFAULT_LOCK_STALE_MS = 60_000;
|
|
@@ -207,7 +227,6 @@ export class CmsWorker {
|
|
|
207
227
|
this.drainDeadlineMs = config.drainDeadlineMs ?? DEFAULT_DRAIN_DEADLINE_MS;
|
|
208
228
|
this.contentRoot = config.contentRoot ?? 'content';
|
|
209
229
|
this.schemaHoldMaxMs = config.schemaHoldMaxMs ?? DEFAULT_SCHEMA_HOLD_MAX_MS;
|
|
210
|
-
this.githubMirror = new GitHubMirror(this.stateDirectory, this.remoteGitPath, this.taskTimeoutMs);
|
|
211
230
|
}
|
|
212
231
|
/**
|
|
213
232
|
* This worker's self-reported status object, initialized on first use.
|
|
@@ -265,13 +284,11 @@ export class CmsWorker {
|
|
|
265
284
|
* Built FRESH on every call, with instance-backed members as functions rather
|
|
266
285
|
* than copied values. See WorkerContext's INVARIANT: a context that captured
|
|
267
286
|
* any of them at construction time would hand the extracted code the pre-test
|
|
268
|
-
* value, which for
|
|
269
|
-
* for real instead of its local fixture repo.
|
|
287
|
+
* value, which for the GitHub gateway means a test's push going to
|
|
288
|
+
* github.com for real instead of its local fixture repo.
|
|
270
289
|
*/
|
|
271
290
|
ctx() {
|
|
272
291
|
return {
|
|
273
|
-
githubOwner: this.config.githubOwner,
|
|
274
|
-
githubRepo: this.config.githubRepo,
|
|
275
292
|
baseBranch: this.baseBranch,
|
|
276
293
|
sanitizedBaseBranch: this.sanitizedBaseBranch,
|
|
277
294
|
taskDir: this.taskDir,
|
|
@@ -283,10 +300,7 @@ export class CmsWorker {
|
|
|
283
300
|
maxTasksPerCycle: this.maxTasksPerCycle,
|
|
284
301
|
maxRetries: this.maxRetries,
|
|
285
302
|
log: this.log,
|
|
286
|
-
|
|
287
|
-
buildGitHubUrl: () => this.buildGitHubUrl(),
|
|
288
|
-
githubMirror: () => this.githubMirror,
|
|
289
|
-
refreshGitHubCredential: () => this.refreshGitHubCredential(),
|
|
303
|
+
github: () => this.github(),
|
|
290
304
|
branchWorkspacePath: (branchRefName) => this.branchWorkspacePath(branchRefName),
|
|
291
305
|
executeTask: (task, signal) => this.executeTask(task, signal),
|
|
292
306
|
pushBranchToGitHub: (branch, signal) => this.pushBranchToGitHub(branch, signal),
|
|
@@ -366,7 +380,7 @@ export class CmsWorker {
|
|
|
366
380
|
// Same shape, same reason: a half-configured credential throws HERE,
|
|
367
381
|
// inside the try, rather than out of `new CmsWorker(...)` where nothing
|
|
368
382
|
// could record it.
|
|
369
|
-
this.
|
|
383
|
+
const github = this.github();
|
|
370
384
|
// Same again. NaN would never expire a schema hold, which is the one
|
|
371
385
|
// thing the bound exists to prevent.
|
|
372
386
|
if (!Number.isFinite(this.schemaHoldMaxMs) || this.schemaHoldMaxMs < 0) {
|
|
@@ -374,10 +388,8 @@ export class CmsWorker {
|
|
|
374
388
|
}
|
|
375
389
|
// BEFORE ensureRemoteGit(): its clone is the first thing to use the
|
|
376
390
|
// credential, and its catch blames the repository rather than the
|
|
377
|
-
// credential. See
|
|
378
|
-
await
|
|
379
|
-
await this.ensureStateDirectoryIsPrivate();
|
|
380
|
-
await this.githubMirror.ensure();
|
|
391
|
+
// credential. See GitHubGateway.prepare().
|
|
392
|
+
await github.prepare();
|
|
381
393
|
await this.resolveBaseBranch();
|
|
382
394
|
await this.ensureRemoteGit();
|
|
383
395
|
await this.recordBaseBranchInRemoteHead();
|
|
@@ -410,7 +422,7 @@ export class CmsWorker {
|
|
|
410
422
|
report.lastFatalError = {
|
|
411
423
|
// [REDACT] Persisted to worker-status.json and served to the browser by
|
|
412
424
|
// the admin panel -- must never carry the bot token a poisoned or
|
|
413
|
-
// failed git URL (
|
|
425
|
+
// failed git URL (the gateway's) can embed.
|
|
414
426
|
message: redactCredentials(getErrorMessage(err)),
|
|
415
427
|
at: new Date().toISOString(),
|
|
416
428
|
phase: 'startup',
|
|
@@ -430,11 +442,7 @@ export class CmsWorker {
|
|
|
430
442
|
const taskInterval = this.config.taskPollInterval ?? 5_000;
|
|
431
443
|
const gitInterval = this.config.gitSyncInterval ?? 5 * 60_000;
|
|
432
444
|
this.scheduleLoop('task queue', () => this.processTaskQueue(), taskInterval);
|
|
433
|
-
|
|
434
|
-
// revoked GitHub credential is noticed. start()'s own initial syncGit()
|
|
435
|
-
// above stays unwrapped -- there is no stale credential to refresh one line
|
|
436
|
-
// after reading it at boot.
|
|
437
|
-
this.scheduleLoop('git sync', () => this.syncGitWithCredentialRefresh(), gitInterval);
|
|
445
|
+
this.scheduleLoop('git sync', () => this.syncGit(), gitInterval);
|
|
438
446
|
if (this.config.refreshAuthCache) {
|
|
439
447
|
const cacheInterval = this.config.authCacheRefreshInterval ?? 15 * 60_000;
|
|
440
448
|
this.scheduleLoop('auth cache refresh', () => this.refreshAuthCache(), cacheInterval);
|
|
@@ -675,7 +683,9 @@ export class CmsWorker {
|
|
|
675
683
|
/**
|
|
676
684
|
* An unconfigured base branch is the one remote.git's HEAD names, which is what the Lambda
|
|
677
685
|
* reads too (GitManager.detectBaseBranch); before remote.git exists, it is GitHub's default
|
|
678
|
-
* branch, which the clone then records as that HEAD.
|
|
686
|
+
* branch, which the clone then records as that HEAD. So it is too when remote.git has no branch
|
|
687
|
+
* outside the settings namespace: no base branch is there for HEAD to name, and ensureRemoteGit
|
|
688
|
+
* replaces it from GitHub unless something in it is at stake. Never assumes 'main'.
|
|
679
689
|
*/
|
|
680
690
|
async resolveBaseBranch() {
|
|
681
691
|
if (this.resolvedBaseBranch)
|
|
@@ -698,15 +708,13 @@ export class CmsWorker {
|
|
|
698
708
|
// Before the first git to read it, and on its own: a refusal is recorded as itself, not as a
|
|
699
709
|
// base branch to configure or a remote.git to delete.
|
|
700
710
|
if (remoteGitExists)
|
|
701
|
-
await
|
|
711
|
+
await this.assertRemoteGitConfig();
|
|
702
712
|
let name;
|
|
703
713
|
try {
|
|
704
|
-
|
|
714
|
+
const fromRemoteGit = remoteGitExists && (await this.hasContentBranch(this.remoteGitPath));
|
|
715
|
+
name = fromRemoteGit
|
|
705
716
|
? await readHeadBranch(this.remoteGitPath, sharedRepoGit(this.remoteGitPath, 'bare'))
|
|
706
|
-
:
|
|
707
|
-
owner: this.config.githubOwner,
|
|
708
|
-
repo: this.config.githubRepo,
|
|
709
|
-
})).data.default_branch;
|
|
717
|
+
: await this.github().defaultBranch();
|
|
710
718
|
}
|
|
711
719
|
catch (err) {
|
|
712
720
|
throw undetermined(err);
|
|
@@ -714,6 +722,33 @@ export class CmsWorker {
|
|
|
714
722
|
this.setBaseBranch(name);
|
|
715
723
|
workerLog(`Base branch: '${name}' (detected; CANOPYCMS_BASE_BRANCH is not set)`);
|
|
716
724
|
}
|
|
725
|
+
/**
|
|
726
|
+
* Check remote.git's config at boot. A refusal is never a case for replacement: it reports that
|
|
727
|
+
* something wrote there, and listing the refs a replacement would lose means running git under
|
|
728
|
+
* that config. The message says so, so System health does not read it as a routine failure.
|
|
729
|
+
*/
|
|
730
|
+
async assertRemoteGitConfig() {
|
|
731
|
+
try {
|
|
732
|
+
await assertSharedRepoConfig(this.remoteGitPath, 'bare');
|
|
733
|
+
}
|
|
734
|
+
catch (err) {
|
|
735
|
+
if (!(err instanceof SharedRepoRefusalError))
|
|
736
|
+
throw err;
|
|
737
|
+
throw new SharedRepoRefusalError(`${err.message.replace(/\.$/, '')}. This needs an operator: the worker never repairs or ` +
|
|
738
|
+
`replaces a remote.git whose config was tampered with or cannot be read.`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/** Whether the bare repo at `gitDir` has a branch that is not a settings branch. */
|
|
742
|
+
async hasContentBranch(gitDir) {
|
|
743
|
+
const branches = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit }).raw([
|
|
744
|
+
'for-each-ref',
|
|
745
|
+
'--format=%(refname:strip=2)',
|
|
746
|
+
'refs/heads/',
|
|
747
|
+
]);
|
|
748
|
+
return branches
|
|
749
|
+
.split('\n')
|
|
750
|
+
.some((branch) => branch !== '' && !isSettingsBranchName(branch, this.ensureSettingsBranch()));
|
|
751
|
+
}
|
|
717
752
|
/**
|
|
718
753
|
* Point remote.git's HEAD at the base branch this worker uses, so a Lambda left to detect it
|
|
719
754
|
* (GitManager.detectBaseBranch) reads the same name. Only the worker writes it, at boot under
|
|
@@ -843,7 +878,7 @@ export class CmsWorker {
|
|
|
843
878
|
* pushed) succeeds and leaves a refs-less repo that `fs.stat` cannot tell from a healthy one, so
|
|
844
879
|
* left unchecked it silently poisons remote.git and the stat short-circuit means it never heals.
|
|
845
880
|
* The base branch is therefore verified right after seeding AND on the already-exists fast
|
|
846
|
-
* path,
|
|
881
|
+
* path, where a poisoned remote.git is replaced (`replacePoisonedRemoteGit`).
|
|
847
882
|
*/
|
|
848
883
|
async ensureRemoteGit() {
|
|
849
884
|
let exists;
|
|
@@ -857,7 +892,7 @@ export class CmsWorker {
|
|
|
857
892
|
if (exists) {
|
|
858
893
|
// At boot, so a refusal lands in worker-status.json as a startup failure, and first: the
|
|
859
894
|
// scrub is git reading this config too.
|
|
860
|
-
await
|
|
895
|
+
await this.assertRemoteGitConfig();
|
|
861
896
|
// SELF-HEAL, before anything else changes this repo: a remote.git cloned from GitHub by an
|
|
862
897
|
// older worker recorded the token-bearing clone URL in its config.
|
|
863
898
|
await this.scrubPersistedRemote(this.remoteGitPath);
|
|
@@ -866,31 +901,135 @@ export class CmsWorker {
|
|
|
866
901
|
}
|
|
867
902
|
catch (err) {
|
|
868
903
|
workerLogError(`remote.git base branch verification failed: ${getErrorMessage(err)}`);
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
// deletion is the operator's call.
|
|
872
|
-
throw new Error(`remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (likely cloned while the GitHub repo was empty). Delete ${this.remoteGitPath} and restart the worker to re-clone.`);
|
|
904
|
+
await this.replacePoisonedRemoteGit();
|
|
905
|
+
return;
|
|
873
906
|
}
|
|
874
907
|
await this.applyRemoteGitConfig(this.remoteGitPath);
|
|
875
908
|
return; // Already exists and has the base branch
|
|
876
909
|
}
|
|
877
910
|
workerLog('Initializing remote.git from GitHub...');
|
|
911
|
+
const stagingPath = await this.seedRemoteGitStaging();
|
|
912
|
+
await fs.rename(stagingPath, this.remoteGitPath);
|
|
913
|
+
workerLog('remote.git initialized');
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Replace an existing remote.git that has no base branch with a fresh seed from GitHub, but only
|
|
917
|
+
* when every ref in it names a commit a GitHub branch contains, so replacing it loses no commit;
|
|
918
|
+
* a branch name GitHub lacks, such as a merged branch deleted there, goes. Otherwise refuse,
|
|
919
|
+
* naming the refs at stake: unpushed work such as a settings branch.
|
|
920
|
+
*
|
|
921
|
+
* The refs are compared in the same mirror session that fetches GitHub and seeds the
|
|
922
|
+
* replacement, and listed again just before the swap, since the Lambda can push into remote.git
|
|
923
|
+
* while the seed runs. The swap is two renames, so the name remote.git resolves to the old repo,
|
|
924
|
+
* the new one, or for an instant nothing, never a half-built or half-deleted one.
|
|
925
|
+
*/
|
|
926
|
+
async replacePoisonedRemoteGit() {
|
|
927
|
+
const poisoned = `remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (cloned while the ` +
|
|
928
|
+
`GitHub repository was empty, or the base branch is set to one it never had)`;
|
|
929
|
+
const notReplaced = (reason) => new Error(`${poisoned}, and it was not replaced: ${reason}`);
|
|
930
|
+
let refs;
|
|
931
|
+
try {
|
|
932
|
+
refs = await listRemoteGitRefs(this.remoteGitPath);
|
|
933
|
+
}
|
|
934
|
+
catch (err) {
|
|
935
|
+
throw notReplaced(`its refs could not all be read (${redactCredentials(getErrorMessage(err)).trim()}), so ` +
|
|
936
|
+
`the worker cannot tell whether replacing it would lose work. Deleting ` +
|
|
937
|
+
`${this.remoteGitPath} and restarting the worker re-clones it and discards every ref in it.`);
|
|
938
|
+
}
|
|
939
|
+
// Only a base branch the listing confirms absent: a check that failed for any other reason
|
|
940
|
+
// is no evidence the repo is poisoned.
|
|
941
|
+
if (refs.has(`refs/heads/${this.baseBranch}`)) {
|
|
942
|
+
throw new Error(`remote.git at ${this.remoteGitPath} has branch '${this.baseBranch}', but git could not ` +
|
|
943
|
+
`verify it, so the worker will not replace remote.git. Restarting the worker checks again.`);
|
|
944
|
+
}
|
|
945
|
+
workerLog(`remote.git has no branch '${this.baseBranch}': replacing it if GitHub has every ref in it`);
|
|
946
|
+
const stagingPath = await this.seedRemoteGitStaging(async (github) => {
|
|
947
|
+
const onGitHub = await github.onGitHub([...refs.values()]);
|
|
948
|
+
const unpushed = [];
|
|
949
|
+
for (const [ref, id] of refs) {
|
|
950
|
+
if (!onGitHub.has(id))
|
|
951
|
+
unpushed.push(ref);
|
|
952
|
+
}
|
|
953
|
+
if (unpushed.length === 0)
|
|
954
|
+
return;
|
|
955
|
+
const shown = unpushed.slice(0, MAX_REFS_LISTED);
|
|
956
|
+
const more = unpushed.length - shown.length;
|
|
957
|
+
throw new RemoteGitKeptError(`${poisoned}, and it holds ${unpushed.length === 1 ? 'a ref' : `${unpushed.length} refs`} ` +
|
|
958
|
+
`GitHub does not have, so the worker will not replace it: ${shown.join(', ')}` +
|
|
959
|
+
`${more > 0 ? ` and ${more} more` : ''}. Deleting ${this.remoteGitPath} and restarting ` +
|
|
960
|
+
`the worker re-clones it and discards ${unpushed.length === 1 ? 'that ref' : 'those refs'}.`);
|
|
961
|
+
}).catch((err) => {
|
|
962
|
+
if (err instanceof RemoteGitKeptError)
|
|
963
|
+
throw err;
|
|
964
|
+
throw notReplaced(redactCredentials(getErrorMessage(err)));
|
|
965
|
+
});
|
|
966
|
+
const discardStaging = () => fs.rm(stagingPath, { recursive: true, force: true });
|
|
967
|
+
let current;
|
|
968
|
+
try {
|
|
969
|
+
current = await listRemoteGitRefs(this.remoteGitPath);
|
|
970
|
+
}
|
|
971
|
+
catch (err) {
|
|
972
|
+
await discardStaging();
|
|
973
|
+
throw notReplaced(`its refs could not all be read again (${getErrorMessage(err).trim()}). Restarting the ` +
|
|
974
|
+
`worker checks again.`);
|
|
975
|
+
}
|
|
976
|
+
if (!sameRefs(refs, current)) {
|
|
977
|
+
await discardStaging();
|
|
978
|
+
throw notReplaced('its refs changed while the worker seeded its replacement. Restarting the worker checks again.');
|
|
979
|
+
}
|
|
980
|
+
const replaced = `${this.remoteGitPath}.replaced-${Date.now()}`;
|
|
981
|
+
try {
|
|
982
|
+
await fs.rename(this.remoteGitPath, replaced);
|
|
983
|
+
}
|
|
984
|
+
catch (err) {
|
|
985
|
+
await discardStaging();
|
|
986
|
+
throw err;
|
|
987
|
+
}
|
|
988
|
+
try {
|
|
989
|
+
await fs.rename(stagingPath, this.remoteGitPath);
|
|
990
|
+
}
|
|
991
|
+
catch (err) {
|
|
992
|
+
await fs.rename(replaced, this.remoteGitPath);
|
|
993
|
+
await discardStaging();
|
|
994
|
+
throw err;
|
|
995
|
+
}
|
|
996
|
+
// A push that resolved remote.git before the rename can still land in the old repo. One that
|
|
997
|
+
// lands before this listing keeps it; a later one is lost from remote.git, though the clone
|
|
998
|
+
// that pushed still has its commit.
|
|
999
|
+
const after = await listRemoteGitRefs(replaced).catch(() => null);
|
|
1000
|
+
if (after !== null && sameRefs(refs, after)) {
|
|
1001
|
+
// remote.git is already replaced, so a push still writing here costs only this directory.
|
|
1002
|
+
await fs
|
|
1003
|
+
.rm(replaced, { recursive: true, force: true, maxRetries: 3 })
|
|
1004
|
+
.catch((err) => {
|
|
1005
|
+
workerLogWarn(`Could not remove the replaced remote.git at ${replaced}: ${getErrorMessage(err)}`);
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
else {
|
|
1009
|
+
workerLogError(`Kept the replaced remote.git at ${replaced}: its refs changed as it was swapped out, so ` +
|
|
1010
|
+
`it may hold work GitHub does not have.`);
|
|
1011
|
+
}
|
|
1012
|
+
workerLog('remote.git replaced');
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Seed a new bare repo from GitHub at `<remote.git>.cloning`, verified and configured, and
|
|
1016
|
+
* return its path for the caller to rename into place. `check` runs in the mirror session right
|
|
1017
|
+
* after the GitHub fetch, and its throw stops the seed. Throws with the staging directory gone.
|
|
1018
|
+
*/
|
|
1019
|
+
async seedRemoteGitStaging(check) {
|
|
878
1020
|
// Seeded under a TEMP name and renamed into place only once verified, so a crash mid-seed
|
|
879
1021
|
// leaves only this staging directory, which the next boot deletes, rather than a poisoned
|
|
880
1022
|
// `remote.git` that fs.stat cannot distinguish from a healthy one.
|
|
881
1023
|
const stagingPath = `${this.remoteGitPath}.cloning`;
|
|
882
1024
|
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
883
1025
|
try {
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
const staging = sharedRepoGit(stagingPath, 'bare');
|
|
892
|
-
await staging.raw(['init', '--quiet', '--bare']);
|
|
893
|
-
await mirror.seedBareRepository(stagingPath);
|
|
1026
|
+
await this.github().seedBareRepository(stagingPath, {
|
|
1027
|
+
baseBranch: this.baseBranch,
|
|
1028
|
+
beforeSeed: check,
|
|
1029
|
+
createRepository: async () => {
|
|
1030
|
+
await fs.mkdir(stagingPath);
|
|
1031
|
+
await sharedRepoGit(stagingPath, 'bare').raw(['init', '--quiet', '--bare']);
|
|
1032
|
+
},
|
|
894
1033
|
});
|
|
895
1034
|
// The staging path is predictable, and so writable by the Lambda while seeding runs.
|
|
896
1035
|
await assertSharedRepoConfig(stagingPath, 'bare');
|
|
@@ -899,11 +1038,15 @@ export class CmsWorker {
|
|
|
899
1038
|
await this.applyRemoteGitConfig(stagingPath);
|
|
900
1039
|
}
|
|
901
1040
|
catch (err) {
|
|
902
|
-
|
|
1041
|
+
if (!(err instanceof RemoteGitKeptError)) {
|
|
1042
|
+
workerLogError(`remote.git seeding failed: ${redactCredentials(getErrorMessage(err))}`);
|
|
1043
|
+
}
|
|
903
1044
|
// Deleting before throwing is what makes this recoverable: the next
|
|
904
1045
|
// start() sees no remote.git and re-clones, instead of sticking forever
|
|
905
1046
|
// behind a poisoned bare repo fs.stat alone cannot detect.
|
|
906
1047
|
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
1048
|
+
if (err instanceof RemoteGitKeptError)
|
|
1049
|
+
throw err;
|
|
907
1050
|
if (err instanceof SharedRepoRefusalError) {
|
|
908
1051
|
// Not the refusal's own advice: the directory it names is gone.
|
|
909
1052
|
throw new SharedRepoRefusalError(`Refusing to run git in ${stagingPath}: something wrote to it while the worker seeded ` +
|
|
@@ -915,22 +1058,7 @@ export class CmsWorker {
|
|
|
915
1058
|
}
|
|
916
1059
|
throw new Error(`remote.git clone of ${this.config.githubOwner}/${this.config.githubRepo} failed or has no branch '${this.baseBranch}' - the GitHub repository may be empty, or the base branch may not exist. Push an initial commit to '${this.baseBranch}' and restart the worker (systemd will retry automatically).`);
|
|
917
1060
|
}
|
|
918
|
-
|
|
919
|
-
workerLog('remote.git initialized');
|
|
920
|
-
}
|
|
921
|
-
/**
|
|
922
|
-
* Refuse a state directory on the shared filesystem: the mirror there would be as writable by
|
|
923
|
-
* the Lambda as remote.git is.
|
|
924
|
-
*/
|
|
925
|
-
async ensureStateDirectoryIsPrivate() {
|
|
926
|
-
const workspace = await realpathOfNearest(this.config.workspacePath);
|
|
927
|
-
const state = await realpathOfNearest(this.stateDirectory);
|
|
928
|
-
const relative = path.relative(workspace, state);
|
|
929
|
-
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
|
930
|
-
throw new Error(`The worker's state directory (${this.stateDirectory}) is inside its shared workspace ` +
|
|
931
|
-
`(${this.config.workspacePath}). It holds the repository the GitHub credential is used ` +
|
|
932
|
-
`in, so it must be somewhere the CMS Lambda cannot write.`);
|
|
933
|
-
}
|
|
1061
|
+
return stagingPath;
|
|
934
1062
|
}
|
|
935
1063
|
// --- Task-queue cluster (worker/task-runner.ts) ------------------------
|
|
936
1064
|
//
|
|
@@ -959,113 +1087,28 @@ export class CmsWorker {
|
|
|
959
1087
|
return pushBranchToGitHub(this.ctx(), branch, signal);
|
|
960
1088
|
}
|
|
961
1089
|
/**
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
* `resolveWorkerGitHubAuth` throws for a half-configured credential (both
|
|
968
|
-
* set, neither set, an unusable mint timeout or refresh interval), and a
|
|
969
|
-
* throw during `new CmsWorker(...)` lands BEFORE start()'s catch, which
|
|
970
|
-
* records `lastFatalError` for every entrypoint.
|
|
971
|
-
*
|
|
972
|
-
* Idempotent, and it does NOT replace an `octokit` a test has already
|
|
973
|
-
* assigned onto the instance — see the field's comment.
|
|
974
|
-
*/
|
|
975
|
-
ensureGitHubAuth() {
|
|
976
|
-
if (!this.githubAuth) {
|
|
977
|
-
this.githubAuth = resolveWorkerGitHubAuth(this.config);
|
|
978
|
-
}
|
|
979
|
-
if (!this.octokit) {
|
|
980
|
-
this.octokit = createCanopyOctokit(this.githubAuth.octokitAuth);
|
|
981
|
-
}
|
|
982
|
-
return this.githubAuth;
|
|
983
|
-
}
|
|
984
|
-
/**
|
|
985
|
-
* The Octokit client, built on first use. Every read goes through here rather
|
|
986
|
-
* than touching the field, which the constructor does not populate: a method
|
|
987
|
-
* reached without start() would otherwise see `undefined`.
|
|
988
|
-
* `rebaseActiveBranches()` is exactly that case — apps/test-app's e2e route
|
|
989
|
-
* calls it directly, and its `pollMergeState` dispatch reads `ctx.octokit()`.
|
|
990
|
-
*/
|
|
991
|
-
octokitClient() {
|
|
992
|
-
this.ensureGitHubAuth();
|
|
993
|
-
return this.octokit;
|
|
994
|
-
}
|
|
995
|
-
/**
|
|
996
|
-
* The single seam through which every git-over-HTTPS credential reaches a git
|
|
997
|
-
* command. The only other consumer is Octokit, built from the same resolution
|
|
998
|
-
* by `ensureGitHubAuth()` above.
|
|
999
|
-
*
|
|
1000
|
-
* Async because under GitHub App auth `resolveGitToken` mints an installation
|
|
1001
|
-
* token lasting about an hour. NOTHING may cache what this returns — a URL
|
|
1002
|
-
* built from an installation token goes stale with it — and resolving per use
|
|
1003
|
-
* is cheap, since `@octokit/auth-app` answers from its own cache until the
|
|
1004
|
-
* token is near expiry.
|
|
1005
|
-
*
|
|
1006
|
-
* A mint failure propagates AS THROWN, carrying the `.status` that
|
|
1007
|
-
* `isPermanentTaskFailure` classifies on — see github-auth.ts.
|
|
1008
|
-
*
|
|
1009
|
-
* Do NOT add a parallel token accessor alongside it. Every instance-backed
|
|
1010
|
-
* WorkerContext member stays a function precisely so tests can replace it
|
|
1011
|
-
* through the instance (worker-context.ts's INVARIANT); a second credential
|
|
1012
|
-
* path would be one nothing stubs.
|
|
1090
|
+
* The worker's GitHub gateway, created on first use: inside start()'s try, or wherever a test
|
|
1091
|
+
* entry point first reaches it. Creating it resolves the credential, which throws for a
|
|
1092
|
+
* half-configured one (both set, neither set, an unusable mint timeout or refresh interval) --
|
|
1093
|
+
* never in the constructor, for the reason `ensureSettingsBranch()` gives. Keeps a gateway a
|
|
1094
|
+
* test installed.
|
|
1013
1095
|
*/
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
return
|
|
1096
|
+
github() {
|
|
1097
|
+
this.gateway ??= createLocalGitHubGateway(this.localGitHubGatewayOptions());
|
|
1098
|
+
return this.gateway;
|
|
1017
1099
|
}
|
|
1018
|
-
/**
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
* load-bearing. Not always fatal, because the two credential paths must
|
|
1031
|
-
* degrade alike: on the token path a GitHub 502 during boot is absorbed (a
|
|
1032
|
-
* warm `remote.git` short-circuits `ensureRemoteGit`, `Promise.allSettled`
|
|
1033
|
-
* swallows the initial `syncGit`) so the worker starts and its loops retry,
|
|
1034
|
-
* while rethrowing every error class would make the App path exit and systemd
|
|
1035
|
-
* crash-loop it until GitHub recovered — each iteration telling the operator
|
|
1036
|
-
* to check their private key.
|
|
1037
|
-
*
|
|
1038
|
-
* But fail CLOSED, via `isTransientAuthFailure` rather than the inverse of
|
|
1039
|
-
* `isPermanentTaskFailure`: that classifier defaults a status-less error to
|
|
1040
|
-
* transient, which is right on the task path (bounded by `maxRetries`) and
|
|
1041
|
-
* wrong here (bounded by nothing). A key that never reaches GitHub at all —
|
|
1042
|
-
* the wrong type, or too mangled to sign with — fails locally and
|
|
1043
|
-
* status-lessly, so defaulting to transient would boot a worker with a dead
|
|
1044
|
-
* credential, record no `lastFatalError`, and show healthy in the admin panel
|
|
1045
|
-
* while every task and every sync failed.
|
|
1046
|
-
*/
|
|
1047
|
-
async preflightGitHubAppAuth() {
|
|
1048
|
-
if (!this.config.githubAppAuth)
|
|
1049
|
-
return;
|
|
1050
|
-
try {
|
|
1051
|
-
await this.ensureGitHubAuth().resolveGitToken();
|
|
1052
|
-
}
|
|
1053
|
-
catch (err) {
|
|
1054
|
-
// [REDACT] Both messages below reach worker-status.json and the browser.
|
|
1055
|
-
const detail = redactCredentials(getErrorMessage(err));
|
|
1056
|
-
if (isTransientAuthFailure(err)) {
|
|
1057
|
-
workerLogWarn(`Could not verify GitHub App authentication at startup: ${detail}. ` +
|
|
1058
|
-
'Continuing — this looks transient, and the credential is minted again on first use.');
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
// Re-thrown WITH context, unlike buildGitHubUrl() above, which must
|
|
1062
|
-
// preserve the error identity for task classification. Nothing classifies
|
|
1063
|
-
// a startup failure -- start()'s catch records the message and the
|
|
1064
|
-
// process exits -- so the operator-facing wording wins here.
|
|
1065
|
-
throw new Error(`GitHub App authentication failed: ${detail}. ` +
|
|
1066
|
-
'Check the app id, the installation id, and that the private key belongs to that app.');
|
|
1067
|
-
}
|
|
1068
|
-
workerLog('GitHub App authentication verified');
|
|
1100
|
+
/** What the in-process gateway is built from; test-utils/worker-gateway.ts builds from it too. */
|
|
1101
|
+
localGitHubGatewayOptions() {
|
|
1102
|
+
return {
|
|
1103
|
+
githubOwner: this.config.githubOwner,
|
|
1104
|
+
githubRepo: this.config.githubRepo,
|
|
1105
|
+
auth: resolveWorkerGitHubAuth(this.config),
|
|
1106
|
+
githubApp: Boolean(this.config.githubAppAuth),
|
|
1107
|
+
stateDirectory: this.stateDirectory,
|
|
1108
|
+
workspacePath: this.config.workspacePath,
|
|
1109
|
+
remoteGitPath: this.remoteGitPath,
|
|
1110
|
+
timeoutMs: this.taskTimeoutMs,
|
|
1111
|
+
};
|
|
1069
1112
|
}
|
|
1070
1113
|
/**
|
|
1071
1114
|
* The workspace directory for a branch named by its GIT REF name -- the form
|
|
@@ -1130,78 +1173,8 @@ export class CmsWorker {
|
|
|
1130
1173
|
async syncGit() {
|
|
1131
1174
|
return syncGit(this.ctx());
|
|
1132
1175
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
*
|
|
1136
|
-
* One of `refreshGitHubCredential`'s two call sites, and the one that works
|
|
1137
|
-
* when nobody is publishing: it fetches from GitHub every `gitSyncInterval`
|
|
1138
|
-
* whether or not anyone is editing, so a credential that has stopped working
|
|
1139
|
-
* surfaces here even with no push queued for days.
|
|
1140
|
-
*
|
|
1141
|
-
* The SYNC failure is what propagates to `scheduleLoop`'s catch;
|
|
1142
|
-
* `refreshGitHubCredential` never throws, so nothing it does can replace it.
|
|
1143
|
-
*/
|
|
1144
|
-
async syncGitWithCredentialRefresh() {
|
|
1145
|
-
try {
|
|
1146
|
-
await this.syncGit();
|
|
1147
|
-
}
|
|
1148
|
-
catch (err) {
|
|
1149
|
-
await this.refreshGitHubCredential();
|
|
1150
|
-
throw err;
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
/**
|
|
1154
|
-
* Re-read the GitHub credential, because an operation using it just failed.
|
|
1155
|
-
*
|
|
1156
|
-
* **Two call sites, each covering what the other cannot.** The git-sync loop
|
|
1157
|
-
* (`syncGitWithCredentialRefresh`) notices a dead credential when nobody is
|
|
1158
|
-
* publishing. `processTaskQueue`'s per-task catch is what saves a publish: a
|
|
1159
|
-
* push task spends its retry budget on a 5s/10s/20s backoff, well inside one
|
|
1160
|
-
* 5-minute sync interval, so with the sync loop as the only trigger a publish
|
|
1161
|
-
* meeting a rotated token fails permanently while the working one is already
|
|
1162
|
-
* in the secret store. Every consumer reaches the credential through
|
|
1163
|
-
* `ensureGitHubAuth()`, which reads it per use, so a refresh from either site
|
|
1164
|
-
* repairs all of them for their NEXT use — but not an attempt already failed.
|
|
1165
|
-
*
|
|
1166
|
-
* NOT gated on the error looking auth-shaped, at either site: a `git
|
|
1167
|
-
* fetch`/`push` rejected for a dead token throws a plain simple-git error
|
|
1168
|
-
* (exit 128, no HTTP `.status`) that `isPermanentTaskFailure` reads as
|
|
1169
|
-
* transient, so a gate keyed on it would never fire. Two floors bound the
|
|
1170
|
-
* cost instead — core's `refreshGitHubTokenMinIntervalMs` (default 60s,
|
|
1171
|
-
* enforced by `refreshCredential` in github-auth.ts) and whatever floor the
|
|
1172
|
-
* provider keeps (the AWS one reads at most once per five minutes) — and both
|
|
1173
|
-
* call sites share both, so a read issued by one throttles the other. On the
|
|
1174
|
-
* GitHub App path the refresh is a no-op.
|
|
1175
|
-
*
|
|
1176
|
-
* **Never throws**: both callers are already reporting the failure that must
|
|
1177
|
-
* reach the log. **Bounded by `taskTimeoutMs`**, because the task loop awaits
|
|
1178
|
-
* it and a read that never settled would stop every publish queued behind it
|
|
1179
|
-
* (an adopter's provider may have no bound at all; the AWS one can take 87s
|
|
1180
|
-
* for one `getSecret`). A losing read is not cancelled and may land later, but
|
|
1181
|
-
* `refreshCredential` discards a result older than one already applied.
|
|
1182
|
-
*/
|
|
1183
|
-
async refreshGitHubCredential() {
|
|
1184
|
-
let timer;
|
|
1185
|
-
const timedOut = new Promise((_, reject) => {
|
|
1186
|
-
timer = setTimeout(() => reject(new Error(`the re-read did not settle within ${this.taskTimeoutMs}ms`)), this.taskTimeoutMs);
|
|
1187
|
-
});
|
|
1188
|
-
try {
|
|
1189
|
-
// `ensureGitHubAuth()` inside the try: it throws for a half-configured
|
|
1190
|
-
// credential, and that has to be logged like any other refresh failure.
|
|
1191
|
-
await Promise.race([this.ensureGitHubAuth().refreshCredential(), timedOut]);
|
|
1192
|
-
}
|
|
1193
|
-
catch (err) {
|
|
1194
|
-
// [REDACT] The message can name the secret and, on a malformed-secret
|
|
1195
|
-
// path, quote what was read. Console only, but the rule is uniform --
|
|
1196
|
-
// see redactCredentials in utils/error.ts.
|
|
1197
|
-
workerLogError('Failed to re-read the GitHub credential after a failure:', redactCredentials(getErrorMessage(err)));
|
|
1198
|
-
}
|
|
1199
|
-
finally {
|
|
1200
|
-
clearTimeout(timer);
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
async pushSettingsBranches(git, trackedNames) {
|
|
1204
|
-
return pushSettingsBranches(this.ctx(), git, trackedNames);
|
|
1176
|
+
async pushSettingsBranches(git, trackedTips) {
|
|
1177
|
+
return pushSettingsBranches(this.ctx(), git, trackedTips);
|
|
1205
1178
|
}
|
|
1206
1179
|
async refreshBaseBranchWorkspace() {
|
|
1207
1180
|
return refreshBaseBranchWorkspace(this.ctx());
|