Compare commits

...
Author SHA1 Message Date
jschoubben 59b13d091d Ask no check of a group member that waits on its composed check, and stop asking a merge refused the same way with no trunk move (issue 1106) 2026-10-21 21:57:32 +02:00
mesh-tools 3793175894 Merge pull request 'Make minio's link-key secrets 40 characters and keep every mc argument's secret out of its errors (issue 1766)' (#422) from fix/minio-link-key-length into main 2026-10-21 19:00:13 +00:00
jschoubben b70f246622 Merge remote-tracking branch 'origin/main' into fix/minio-link-key-length
mesh/merge-gate head b70f24662298 merged into trunk cdbdb4747474 — pass: builds minio, sent nowhere; no bus step; 1 wait(s) for a person; every machine com…
mesh/repo-check head b70f24662298 merged into trunk cdbdb4747474 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 20:52:18 +02:00
jschoubben 689e61081d Merge remote-tracking branch 'origin/main' into fix/minio-link-key-length
mesh/merge-gate head 689e61081df9 merged into trunk cdbdb4747474 — pass: builds minio, sent nowhere; no bus step; 1 wait(s) for a person; every machine com…
mesh/repo-check head 689e61081df9 merged into trunk cdbdb4747474 — fail: its merge-check.sh failed: mesh-controller: 1 problem(s) in 139 manifest(s): modul…
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 20:22:24 +02:00
jschoubben c83f48b821 Make link-key secrets 40 characters and keep every mc argument's secret out of its errors (issue 1766) 2026-10-21 20:21:56 +02:00
11 changed files with 232 additions and 9 deletions
+9 -1
View File
@@ -12,7 +12,7 @@ import { mergeableUnder, newestByContext } from "./checks.js";
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk } from "./trunk.js";
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
@@ -117,6 +117,14 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`);
const head = twelve(pull.head_sha), now = twelve(trunkHead);
// **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own
// check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was
// refused the same way for ever. The composed check is mesh-delivery's to ask; this says whose verdict is awaited.
if (waitsForComposed(checks[CHECK_CONTEXT])) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[CHECK_CONTEXT]?.description}; the ` +
`verdict that names its trunk commit is its group's composed check's, and mesh-delivery asks it, this tool asks no check ` +
`(main is now ${now}). Ask again once the group's composed check has passed. Nothing was merged`);
}
if (at.onItsWay) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
`${now}. Its check is already asked and not yet answered (${checks[CHECK_CONTEXT]?.description}), so it is not ` +
+5 -1
View File
@@ -226,6 +226,10 @@ function repoWrong(repo: Layer | undefined, facts: RepoCheckFacts): boolean {
return !!repo && repoStatus(repo, facts).state !== "success";
}
/** What a group member's gate says while the group's composed check is the one that judges it: the member's own check can say
* nothing more of it, so asking that check again brings no verdict that names a trunk (novox/hq issue 1106). */
export const WAITS_FOR_COMPOSED = "waits for its group's composed check";
/** The forge's status for the gate. */
export function statusFor(c: Checked): CommitStatus {
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
@@ -260,7 +264,7 @@ export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: st
// set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it.
const out: CommitStatus[] = [];
if (!(gateNow ?? "").includes(" composed: ")) {
out.push({ state: "pending", context: CHECK_CONTEXT, description: "waits for its group's composed check" });
out.push({ state: "pending", context: CHECK_CONTEXT, description: WAITS_FOR_COMPOSED });
}
const repo = c["repo-check"];
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
+24
View File
@@ -339,6 +339,30 @@ test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk
assert.equal(state.emitted.length, 4, "a merge asks no check");
});
// **A group member whose gate waits for its group's composed check is not announced again by the merge tool** (novox/hq issue
// 1106): the member's own check can only say "composed" again, so each refusal's ask brought a verdict that names no trunk and
// the next merge was refused the same way, for ever. The tool says whose verdict is awaited and asks nothing.
test("gitea_merge_pull_request asks no check of a member that waits for its group's composed check", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
state.trunk = TRUNK;
const before = state.emitted.length;
state.statuses = [{ context: "mesh/merge-gate", state: "pending", description: "waits for its group's composed check" },
{ context: "mesh/repo-check", state: "success", description: "pass: y" }];
for (let i = 0; i < 3; i++) {
await assert.rejects(merge(), (err: Error) => {
assert.match(err.message, /waits for its group's composed check/);
assert.match(err.message, /mesh-delivery asks it, this tool asks no check/);
assert.match(err.message, /Nothing was merged/);
return true;
});
}
assert.equal(state.emitted.length, before, "no check is announced again and again");
assert.equal(state.merged, false);
// Once the composed verdict names the member's unmoved trunk, it merges.
state.statuses = judgedAt(TRUNK);
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
});
// A merge lands only the head that was reviewed (novox/hq issue 381): mesh-controller#197 was reviewed at one head,
// force-pushed, checked green at the new one, and merged there, because the tool merged whatever the head was.
test("gitea_merge_pull_request merges only the head it is named, and refuses one that is not the head", async () => {
+6 -1
View File
@@ -17,7 +17,7 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, WAITS_FOR_COMPOSED } from "./pulls.js";
/** A commit as a verdict names it: its first twelve characters. */
export function twelve(sha: string | undefined): string {
@@ -58,6 +58,11 @@ export function checkOnItsWay(gate: HeadCheck | undefined): boolean {
return d.startsWith("waiting in the build queue") || d.startsWith("building on ") || d.startsWith("checking again");
}
/** Whether a gate says it waits for its group's composed check. */
export function waitsForComposed(gate: HeadCheck | undefined): boolean {
return !!gate && gate.state === "pending" && (gate.description ?? "").startsWith(WAITS_FOR_COMPOSED);
}
/**
* The statuses a trunk's move puts back to pending on one open pull request's head, from what the forge holds on it
* now: none when it has no merge check yet, when a check is pending (on its way, or already out of date — its
@@ -634,6 +634,7 @@ func (h *Holder) answered(key string, ans sdk.Answer) []func() {
for _, d := range covered {
r := d.Ready
r.Warrant = ans.Words
r.Repeats = 0 // the operator chose: whatever the tool refused before is asked afresh (issue 1106)
h.mergeAgainNow(d) // the operator chose: the merge is asked at once
switch ans.Option {
case OptionApprove:
@@ -173,3 +173,43 @@ func TestAGroupMergedWholeMovesSourceMainOnceAndAnnouncesWithTheTeller(t *testin
t.Fatalf("source.main moved again: %d", len(w.source.moves))
}
}
// 1106: the same refusal again and again with the trunk not moved is not asked on without end. Three refusals in a row with
// the same cause end the asking by a verdict, a status or a composed check, and say it urgent; a trunk's move, or the
// operator's Approve, starts it again.
func TestARefusalThatRepeatsWithNoTrunkMoveIsNotAskedAgainWithoutEnd(t *testing.T) {
w := answeredFirst(t)
d := w.h.deliveries[IDOf(gCat, gHk)]
refuse := func(took string) {
w.h.ForgeRefused(sdk.Forge{Kind: sdk.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge",
Words: "novox/mesh-catalog#2 is not merged: its verdict names no trunk commit it was judged against, and main is now f70a99dbf485. " +
"The check of head d2d2d2d2d2d2 merged into trunk f70a99dbf485 was asked; the last check took " + took + ". Ask again once both statuses pass"})
}
tried := w.now
for i, took := range []string{"4m1s", "4m9s"} {
refuse(took)
d.Ready.LastTry = tried
w.h.mergeAgainNow(d)
if !d.Ready.LastTry.IsZero() {
t.Fatalf("refusal %d: the merge was not asked again at once on a verdict, as after a first refusal", i+1)
}
}
refuse("4m5s") // the third in a row, with only the check's duration changed
if d.Ready.Repeats != 3 {
t.Fatalf("%d refusals counted for the same cause, wanted 3", d.Ready.Repeats)
}
d.Ready.LastTry = tried
w.h.mergeAgainNow(d) // the composed check or a verdict answers: nothing is asked
if d.Ready.LastTry.IsZero() {
t.Fatal("the merge was asked again after the same refusal three times with the trunk not moved")
}
if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "names no trunk commit") {
t.Fatalf("the repeated refusal is not said urgent: %+v", lines)
}
// The trunk moves: the merge is asked again.
d.TrunkNow = "a1a1a1a1a1a1a1a1"
w.h.mergeAgainNow(d)
if !d.Ready.LastTry.IsZero() {
t.Fatal("a trunk's move did not let the merge be asked again")
}
}
@@ -287,9 +287,20 @@ func (h *Holder) ForgeRefused(f sdk.Forge) {
if r.RefusedSince == nil {
r.RefusedSince = &now
}
r.noteRefusal(d, answer)
if !outOfDateRefusal(answer) {
r.Urgent = true // never a warning again for this head
}
if r.Repeats >= maxRepeatedRefusals {
// Loud, not endless (novox/hq issue 1106): the same refusal again with the trunk not moved is no trunk's move, so
// the merge is not asked again by a verdict or a status; the operator is told, urgent, and decides.
r.Urgent = true
if r.Repeats == maxRepeatedRefusals {
h.Logf("[mesh-delivery] %s: the merge tool refused it %d times in a row with the same words and the trunk not moved, so "+
"the merge is asked no more until the trunk moves, a new head is announced or the operator approves again: %s",
d.ID, r.Repeats, answer)
}
}
if answer != r.Answer {
h.Logf("[mesh-delivery] %s is approved, and the merge tool did not merge it (asked again at the next verdict, Approve or composed check): %s", d.ID,
answer)
@@ -85,6 +85,11 @@ type ReadyRun struct {
// Urgent is set once a refusal of this head was one that is urgent at once: its severity never steps down to a
// warning after (issue 1275, AC6).
Urgent bool `json:"urgent,omitempty"`
// Repeats is how many times in a row the merge tool refused with the same words while the trunk stayed where RepeatTrunk
// says: past maxRepeatedRefusals the merge is asked no more by a verdict, a status or a composed check, and the refusal
// is urgent (novox/hq issue 1106). A trunk's move, a new head or the operator's Approve starts the count again.
Repeats int `json:"repeats,omitempty"`
RepeatTrunk string `json:"repeat_trunk,omitempty"`
// RefusedSince is when the merge tool began refusing it without a break.
RefusedSince *time.Time `json:"refused_since,omitempty"`
// Merged is when the merge tool merged it.
@@ -322,10 +327,43 @@ func (h *Holder) askFresh(d *Delivery, now time.Time) func() {
// verdict, an Approve, a group's composed check), so the wait after a refusal does not hold it. Under the lock.
func (h *Holder) mergeAgainNow(d *Delivery) {
if d != nil && d.Ready != nil && d.Ready.Merged == nil {
if d.Ready.repeatedRefusal(d) {
return // the same refusal again and again with the trunk where it was: asking more changes nothing (issue 1106)
}
d.Ready.LastTry = time.Time{}
}
}
// maxRepeatedRefusals is how many refusals in a row, with the same words and the trunk not moved, end the asking again: a
// refusal that repeats with no trunk move is not a trunk's move, and the check asked after each only brings the same words.
const maxRepeatedRefusals = 3
// repeatedRefusal is whether the merge was refused maxRepeatedRefusals times with these words and the trunk where it is
// now: it is then asked no more until something changes (a trunk's move, a new head, the operator's Approve).
func (r *ReadyRun) repeatedRefusal(d *Delivery) bool {
return r.Repeats >= maxRepeatedRefusals && r.RepeatTrunk == d.TrunkNow
}
// refusalKey is a refusal's first sentence, up to the trunk it names ("... and main is now <t>"): what follows says how long the
// last check took and the like, which differs each time and is no change of cause.
func refusalKey(answer string) string {
if i := strings.Index(answer, " is now "); i >= 0 {
if j := strings.Index(answer[i:], ". "); j >= 0 {
return answer[:i+j]
}
}
return answer
}
// noteRefusal counts a refusal: the same words with the trunk where it was count on, anything else starts the count at one.
func (r *ReadyRun) noteRefusal(d *Delivery, answer string) {
if refusalKey(answer) == refusalKey(r.Answer) && r.RepeatTrunk == d.TrunkNow && r.Repeats > 0 {
r.Repeats++
return
}
r.Repeats, r.RepeatTrunk = 1, d.TrunkNow
}
// readyWaits is what a delivery's merge waits on; empty when the Approve may be asked and the merge asked. A ready
// delivery is asked. So is a proposed one whose verdict went out of date, when an Approve was asked for it already (it
// stays through a trunk move, ADR 0421 §3) and its check against the trunk's head is not on its way; one without an
+36 -5
View File
@@ -256,11 +256,22 @@ export class MinioClient {
if (argv.some((a) => a.includes(this.rootPassword))) {
throw new Error("refused to run mc: its command line would carry the root password (novox/hq issue 282)");
}
const { stdout } = await execFileAsync(this.mcBin, argv, {
timeout: 30_000,
env: { ...process.env, MC_HOST_mesh: mcHost(this.baseUrl, this.rootUser, this.rootPassword) },
});
return stdout.trim();
const mcHostValue = mcHost(this.baseUrl, this.rootUser, this.rootPassword);
try {
const { stdout } = await execFileAsync(this.mcBin, argv, {
timeout: 30_000,
env: { ...process.env, MC_HOST_mesh: mcHostValue },
});
return stdout.trim();
} catch (err) {
// A fresh error with only the redacted message: execFile's own carries the whole command line, and
// its cmd, stdout and stderr properties would carry it on (issue 1766).
const known = [mcHostValue, this.rootPassword, encodeURIComponent(this.rootPassword), ...secretArgs(argv)];
const e = err as { stderr?: unknown; message?: string };
const reason = typeof e.stderr === "string" && e.stderr.trim() !== "" ? e.stderr.trim() : (e.message ?? String(err));
const shown = argv.filter((a) => a !== this.mcConfigDir).join(" ");
throw new Error(redactSecrets(`mc ${shown} failed: ${reason}`, known));
}
}
// --- SigV4 request plumbing ---------------------------------------------
@@ -360,6 +371,26 @@ function uriEncode(str: string, encodeSlash: boolean): string {
return out;
}
/** The values that follow --secret-key and --access-key in an argument list. */
function secretArgs(argv: string[]): string[] {
const out: string[] = [];
argv.forEach((a, i) => { if ((a === "--secret-key" || a === "--access-key") && i + 1 < argv.length) out.push(argv[i + 1]); });
return out;
}
/**
* The one redaction every `mc` failure goes through (issue 1766): the token after --secret-key or
* --access-key (as a separate word or after "="), any MC_HOST_ value, and each known secret, wherever they
* appear in the text, become "[redacted]".
*/
export function redactSecrets(text: string, known: string[] = []): string {
let out = text
.replace(/(--(?:secret|access)-key(?:=|\s+))('[^']*'|"[^"]*"|\S+)/g, "$1[redacted]")
.replace(/(MC_HOST_\w+=)\S+/g, "$1[redacted]");
for (const k of known) if (k !== "") out = out.split(k).join("[redacted]");
return out;
}
/**
* The MC_HOST_<alias> URL `mc` reads an alias from: the endpoint with the credentials in its userinfo,
* each percent-encoded so a reserved character in either cannot end it early.
+7 -1
View File
@@ -36,6 +36,9 @@ export function linkPolicy(bucket: string): string {
});
}
/** A link key's secret: 40 hex characters, minio's limit for a secret key (issue 1766). */
const LINK_SECRET = /^[0-9a-f]{40}$/;
export function linkSecretPath(dir: string, bucket: string): string {
return join(dir, `${bucket}.secret`);
}
@@ -47,9 +50,12 @@ export async function ensureLinkKey(admin: LinkAdmin, dir: string, bucket: strin
const path = linkSecretPath(dir, bucket);
mkdirSync(dir, { recursive: true, mode: 0o700 });
let secret = existsSync(path) ? readFileSync(path, "utf8").trim() : "";
// A stored secret of the wrong shape (an earlier version made 64 characters; minio takes at most 40) is
// replaced, and its key made again (issue 1766).
if (!LINK_SECRET.test(secret)) secret = "";
if (secret !== "" && (await admin.accessKeyExists(id))) return;
if (secret === "") {
secret = randomBytes(32).toString("hex");
secret = randomBytes(20).toString("hex");
writeFileSync(path, secret + "\n", { mode: 0o600 });
chmodSync(path, 0o600);
}
+55
View File
@@ -115,3 +115,58 @@ test("the link key id is the one the object-store tool derives (cmd/object-store
// 1516/AC1
assert.equal(linkKeyId("brother"), "link0b4bd77cec705cf5");
});
// Issue 1766: a link key's secret is minio's length, and no mc failure carries an argument's secret.
import { chmodSync, writeFileSync, mkdirSync } from "node:fs";
import { MinioClient } from "../client.ts";
test("a link key's secret is 40 characters, and a stored 64-character secret is replaced", async () => {
// 1766/AC1
const { minio, dir } = setup();
await ensureLinkKey(minio, dir, "fresh");
assert.equal(minio.keys.get(linkKeyId("fresh"))!.secret.length, 40);
assert.match(minio.keys.get(linkKeyId("fresh"))!.secret, /^[0-9a-f]{40}$/);
mkdirSync(dir, { recursive: true });
const old = "ab".repeat(32);
writeFileSync(linkSecretPath(dir, "old"), old + "\n");
await ensureLinkKey(minio, dir, "old");
const made = minio.keys.get(linkKeyId("old"))!.secret;
assert.equal(made.length, 40);
assert.notEqual(made, old);
assert.equal(readFileSync(linkSecretPath(dir, "old"), "utf8").trim(), made);
});
function failingMc(): { client: MinioClient; root: string } {
const d = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "minio-mc-"));
const bin = join(d, "mc");
// Says its arguments and its alias on stderr, then fails, the way a refused command does.
writeFileSync(bin, '#!/bin/sh\necho "args: $*" >&2\necho "env: MC_HOST_mesh=$MC_HOST_mesh" >&2\nexit 1\n');
chmodSync(bin, 0o755);
const root = "RootPass-s3cret/ok";
const client = new MinioClient({ endpoint: "http://127.0.0.1:9000", rootUser: "root", rootPassword: root, region: "us-east-1", mcBin: bin, mcConfigDir: d });
return { client, root };
}
test("an mc failure's message holds no --secret-key value and no MC_HOST value", async () => {
// 1766/AC2
const { client, root } = failingMc();
const secret = "SuperSecretValue0123456789";
const err: any = await client.createAccessKey("b", "ACCESSKEYID1234567", secret).then(() => null, (e) => e);
assert.ok(err, "the failure is raised");
for (const text of [String(err.message), String(err.stack ?? ""), JSON.stringify(Object.getOwnPropertyNames(err).map((k) => err[k]))]) {
assert.ok(!text.includes(secret), "no secret key");
assert.ok(!text.includes("ACCESSKEYID1234567"), "no access key");
assert.ok(!text.includes(root) && !text.includes(encodeURIComponent(root)), "no root password");
assert.ok(!text.includes("root:"), "no MC_HOST value");
}
assert.match(err.message, /\[redacted\]/);
});
test("a consumer key's create passes the minted secret unchanged", async () => {
// 1766/AC3
const s = setup();
await s.h.create(provision);
assert.equal(s.minio.keys.get("brother-key")!.secret, "consumer-secret");
});