Skip to content
Merged
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
20 changes: 17 additions & 3 deletions deploy/aws/ami/scripts/02-configure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,27 @@ grep -qx 'PermitRootLogin prohibit-password' /etc/ssh/sshd_config.d/00-libredb-m
|| { echo "FATAL: the sshd drop-in is missing PermitRootLogin prohibit-password" >&2; exit 1; }

sshd -t || { echo "FATAL: sshd config does not parse" >&2; exit 1; }
# Captured once, into a variable: it fails closed on its own under `set -e`, it
# does not run sshd twice, and the failure below can print what it actually saw
# - which is what the allow-list version could not do, and why diagnosing it
# cost a whole second AMI build.
effective_sshd=$(sshd -T)
# A here-string, not a pipe: `grep -q` exits on its first match, and under
# `set -o pipefail` the producer's SIGPIPE (141) would surface as the whole
# command failing - aborting the build with the exact opposite of what happened.
grep -qx 'passwordauthentication no' <<<"$(sshd -T)" \
grep -qx 'passwordauthentication no' <<<"$effective_sshd" \
|| { echo "FATAL: effective sshd config still permits password authentication" >&2; exit 1; }
grep -qE '^permitrootlogin (no|prohibit-password|forced-commands-only)$' <<<"$(sshd -T)" \
|| { echo "FATAL: effective sshd config still permits root password login" >&2; exit 1; }
# Stated as what AWS forbids rather than as a list of the spellings that are
# allowed: `yes` is the only value that permits a root password login, and an
# allow-list of the others rejected a correct image on the second real build.
# The value sshd reports is not the value you wrote - every OpenSSH since 7.0
# prints `without-password`, the deprecated synonym, because that spelling comes
# first in its multistate table - so an allow-list has to track upstream's
# spelling, while the forbidden value has no synonym to miss.
if grep -qx 'permitrootlogin yes' <<<"$effective_sshd"; then
echo "FATAL: effective sshd config still permits root password login: $(grep -m1 '^permitrootlogin ' <<<"$effective_sshd")" >&2
exit 1
fi

# Build-time substitutions — must run AFTER the files are in place. One line per
# token; each token appears exactly once here and once in the scan below.
Expand Down
13 changes: 6 additions & 7 deletions docs/CHANNELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,9 @@ hand. To propose a new channel, add an entry with a `category` and a `platforms`
list, then run `bun run distribution:matrix`. Freshness is enforced on pull
requests with `bun run distribution:matrix --check`.

Not counted here, and why. **AWS Marketplace** has a registered seller account
but no submitted product, so there is nothing to track yet. **Alibaba Cloud** is
not being pursued. **Coolify** declined the submission: its maintainers accept
service templates only from projects above 1000 GitHub stars. **Dokku** has no
application catalog to apply to. A row appears above the moment a submission
exists — `pending` while it is open or under review, `live` once the product can
be installed from that channel.
Not counted here, and why. **Alibaba Cloud** is not being pursued. **Coolify**
declined the submission: its maintainers accept service templates only from
projects above 1000 GitHub stars. **Dokku** has no application catalog to apply
to. A row appears above as soon as there is something to track — a submission,
or a descriptor in this repo that a workflow reads — and stays `pending` until
the product can be installed from that channel, when it becomes `live`.
83 changes: 78 additions & 5 deletions tests/unit/aws-ami-descriptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,25 @@ describe("AWS AMI SSH policy", () => {
test("the effective config is asserted at build time, before the host keys go", () => {
// A here-string rather than a pipe, so `grep -q` closing early cannot make
// pipefail report a correct config as a failure.
expect(configure).toMatch(/grep -qx 'passwordauthentication no' <<<"\$\(sshd -T\)"/);
expect(configure).toMatch(/grep -qE '\^permitrootlogin [^']*' <<<"\$\(sshd -T\)"/);
expect(configure).toMatch(/effective_sshd=\$\(sshd -T\)/);
expect(configure).toMatch(/grep -qx 'passwordauthentication no' <<<"\$effective_sshd"/);
// Written as the forbidden value rather than as an allow-list of the three
// permitted ones: every OpenSSH since 7.0 reports `without-password` for
// `prohibit-password`, so an allow-list has to track upstream's spelling
// while `yes` has no synonym to miss.
// The whole construct, not the grep alone: asserting the text leaves the
// polarity free, and an inverted check rejects every correct image - which
// is the bug this line was written to fix.
expect(configure).toMatch(
/if grep -qx 'permitrootlogin yes' <<<"\$effective_sshd"; then\n[^\n]*FATAL[^\n]*\n\s+exit 1\n\s*fi/,
);
// And it prints what it saw: the allow-list version could not, which is why
// diagnosing its misfire cost a whole second AMI build.
expect(configure).toMatch(/FATAL: effective sshd config still permits root password login: \$\(grep/);
// The allow-list as a CLASS, not as the one spelling that was there before:
// rewriting it as `(without-password|no|forced-commands-only)` is the same
// bug and would pass a literal-prefix guard.
expect(configure).not.toMatch(/permitrootlogin \([^)]*\|/);
expect(cleanup).not.toContain("sshd -T");
});
});
Expand Down Expand Up @@ -386,11 +403,61 @@ describe("AWS AMI build workflow", () => {
});

test("a machine path stands down where a named dispatch fails loudly", () => {
expect(decide).toMatch(/stand_down\(\)[^\n]*run=false/);
expect(decide).toMatch(/explicit=\$\(\[ -n "\$INPUT_VERSION" \]/);
// Through the `exit 0`: a stand_down that writes run=false and then falls
// through is overwritten by the run=true at the end, which would turn every
// stand-down in this step into a no-op.
expect(decide).toMatch(/stand_down\(\)[^\n]*run=false[^\n]*exit 0/);
// Through both arms: inverting them makes every machine path "explicit",
// and a plain release then builds while the channel is not live.
expect(decide).toMatch(/explicit=\$\(\[ -n "\$INPUT_VERSION" \] && echo yes \|\| echo no\)/);
// And the input it reads is the dispatch input alone - falling back to the
// release tag here would make every release look like a person.
expect(workflow).toMatch(/INPUT_VERSION: \$\{\{ inputs\.version \}\}/);
expect(decide).toMatch(/if \[ "\$explicit" = yes \]; then echo "::error::\$1"; exit 1; fi/);
});

test("no machine path builds while the channel is not live", () => {
// The listing gate. Keyed on whether the run NAMES a version rather than on
// the event name: the release chain this workflow is meant to join arrives
// as a workflow_dispatch, so an event-name test would let exactly the path
// the gate exists for walk straight past it. A person who names a version
// still builds - that is how the AMI for the first submission gets made.
expect(decide).toContain('$0 == " - id: aws-marketplace"');
// The `- id:` bound is load-bearing: without it, a row missing its status
// makes awk read the NEXT channel's - `live` - and the gate opens.
expect(decide).toMatch(/found && \/\^ {2}- id: \/ \{ exit \}/);
expect(decide).toMatch(/found && \/\^ {4}status: \/ \{ print \$2; exit \}/);
expect(decide).toContain('if [ "$CHANNEL_STATUS" != live ]; then');
// Scoped to the gate, and asserting the STRUCTURE rather than the presence
// of two strings: `stand_down` lifted out of the exemption keeps both
// substrings and stands every path down, including the named-version
// dispatch - the same lockout the literal flip above is caught for.
const gate = decide.slice(decide.indexOf("CHANNEL_STATUS=$(awk"), decide.indexOf("# Chart releases"));
expect(gate).toMatch(/if \[ "\$explicit" = no \]; then\n\s+stand_down "aws-marketplace is/);
// And that the exemption exempts: one stand_down in the block, with the
// named-version path falling through to a notice. A second stand_down after
// the `fi` keeps every substring above and locks the first AMI out.
expect(gate).toMatch(/fi\n\s+echo "::notice::aws-marketplace is/);
expect(gate.match(/stand_down/g)).toHaveLength(1);
expect(workflow).not.toContain("github.event_name");

// And the row the gate reads has to exist, matched the way the awk matches
// it - a substring test would pass for `aws-marketplace-something`, which
// the workflow would never find - and bounded to its own block, because a
// slice running to the end of the file would match the next channel's
// status and pass whatever this one said.
const channels = fs.readFileSync(path.join(AMI, "../../../distribution/channels.yaml"), "utf8");
const idLine = /^ {2}- id: aws-marketplace$/m.exec(channels);
expect(idLine).not.toBeNull();
const from = (idLine as RegExpExecArray).index;
const next = channels.indexOf("\n - id:", from + 1);
const entry = channels.slice(from, next === -1 ? undefined : next);
expect(entry).toMatch(/^ {4}status: \w+$/m);
// The category too, because a row moved out of cloud-marketplaces is a row
// the marketplace scorecard stops counting while the gate keeps reading it.
expect(entry).toMatch(/^ {4}category: cloud-marketplaces$/m);
});

test("chart releases never build a product AMI", () => {
// libredb-studio-<chart version> tags emit release:published too.
expect(decide).toMatch(/libredb-studio-\*\) stand_down/);
Expand Down Expand Up @@ -430,7 +497,13 @@ describe("AWS AMI build workflow", () => {
test("run=true is written after every check, never before one", () => {
const go = decide.indexOf('echo "run=true"');
expect(go).toBeGreaterThan(0);
for (const check of ["libredb-studio-*", "is not a product release", "does not match package.json", "is not set"]) {
for (const check of [
"aws-marketplace is",
"libredb-studio-*",
"is not a product release",
"does not match package.json",
"is not set",
]) {
expect(decide.indexOf(check)).toBeLessThan(go);
}
expect(decide.lastIndexOf("exit 1")).toBeLessThan(go);
Expand Down
Loading