~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The proxy holds knotless.page's certificate, and serve answers its sites

Merged chadtmiller.com asked to merge into feature/pages from chadtmiller.com/knotless:pages/deploy
.knotless/workflows/deploy.yml+4−0
@@ -42,6 +42,10 @@ secrets: [DEPLOY_SSH_KEY, RELEASE_PASSWORD, RUNNER_HOST_SSH_KEY]
env:
KNOTLESS_HOST: root@knotless.dev
KNOTLESS_DOMAIN: knotless.dev
# Repositories' sites (docs/design/pages.md), whose certificate the
# proxy holds once deploy/setup.sh has run with this and the zone's
# token.
KNOTLESS_PAGES_DOMAIN: knotless.page
KNOTLESS_DATA: /mnt/data/knotless
KNOTLESS_ADMIN_DIDS: did:plc:bcgltzqazw5tb6k2g3ttenbj
# The runner's account (ci.knotless.dev), the triager's
crates/knotless/src/jetstream.rs+4−3
@@ -58,9 +58,9 @@ pub struct Op {
/// Whether `collection` is one knotless writes, the forge reads, or a
/// runner reports in, counts minutes in, or is asked in; a star, a
/// follow, a release, a theme for the pages, or an organization's
/// membership, on either side; a runner's key; or one an account's profile
/// is made of.
/// follow, a release, a site, a theme for the pages, or an
/// organization's membership, on either side; a runner's key; or one an
/// account's profile is made of.
pub fn knotless(collection: &str) -> bool {
nsid::ALL.contains(&collection)
|| nsid::FORGE.contains(&collection)
@@ -72,6 +72,7 @@ pub fn knotless(collection: &str) -> bool {
nsid::FOLLOW,
nsid::REACTION,
nsid::RELEASE,
nsid::SITE,
nsid::THEME,
nsid::ORG,
nsid::ORG_MEMBER,
crates/knotless/src/release.rs+8−0
@@ -101,6 +101,14 @@ pub fn sha256(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// Whether `hash` is one [`sha256`] gives: 64 lowercase hex digits. A
/// record's hash names the file its bytes are kept in, so one that is not
/// could name a path outside where they are kept.
pub fn valid_sha256(hash: &str) -> bool {
let hex = |b: u8| matches!(b, b'0'..=b'9' | b'a'..=b'f');
hash.len() == 64 && hash.bytes().all(hex)
}
/// Publishes `spec` as the owner of `url`'s repository, logged in: its
/// files uploaded, then its record created. Returns the record's AT-URI.
/// A version published before is refused, before anything is uploaded,
crates/knotless-serve/src/index.rs+61−2
@@ -13,8 +13,9 @@ use jiff::{Timestamp, tz::TimeZone};
use knotless::aturi::{AtUri, SpaceRef};
use knotless::forge::{Entry, Item};
use knotless::records::{
FailedCheck, OrgMemberRecord, OrgRecord, ReleaseRecord, RepoRecord, RepoRef, nsid,
FailedCheck, OrgMemberRecord, OrgRecord, ReleaseRecord, RepoRecord, RepoRef, SiteRecord, nsid,
};
use knotless::release::valid_sha256;
use knotless::secrets::Secret;
use knotless::usage::{Day, Network};
use rusqlite::functions::{Context, FunctionFlags};
@@ -594,6 +595,8 @@ pub(crate) fn connect(path: &Path) -> rusqlite::Result<(Connection, bool)> {
uri TEXT PRIMARY KEY, did TEXT NOT NULL, repo_key TEXT NOT NULL,
created_at TEXT NOT NULL, value TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS release_repo ON release (did, repo_key, created_at);
CREATE TABLE IF NOT EXISTS site (
uri TEXT PRIMARY KEY, did TEXT NOT NULL, repo_key TEXT NOT NULL, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS theme (
uri TEXT PRIMARY KEY, did TEXT NOT NULL, created_at TEXT NOT NULL,
name TEXT NOT NULL, value TEXT NOT NULL);
@@ -773,7 +776,7 @@ const CACHE: &str = "DROP TABLE IF EXISTS repo; DROP TABLE IF EXISTS forge;
DROP TABLE IF EXISTS profile_record; DROP TABLE IF EXISTS handle;
DROP TABLE IF EXISTS home;
DROP TABLE IF EXISTS star; DROP TABLE IF EXISTS follow; DROP TABLE IF EXISTS comment;
DROP TABLE IF EXISTS reaction; DROP TABLE IF EXISTS release; DROP TABLE IF EXISTS mention;
DROP TABLE IF EXISTS reaction; DROP TABLE IF EXISTS release; DROP TABLE IF EXISTS site; DROP TABLE IF EXISTS mention;
DROP TABLE IF EXISTS reviewer; DROP TABLE IF EXISTS assigned;
DROP TABLE IF EXISTS org; DROP TABLE IF EXISTS org_member; DROP TABLE IF EXISTS membership;
DROP TABLE IF EXISTS collaborator; DROP TABLE IF EXISTS shared;
@@ -848,6 +851,7 @@ const FORGET_REPO: &[&str] = &[
"DELETE FROM forge WHERE repo_did = ?1 AND repo_key = ?2",
"DELETE FROM check_run WHERE repo_did = ?1 AND repo_key = ?2",
"DELETE FROM release WHERE did = ?1 AND repo_key = ?2",
"DELETE FROM site WHERE uri = 'at://' || ?1 || '/dev.knotless.site/' || ?2",
"DELETE FROM shared WHERE did = ?1 AND repo_key = ?2",
"DELETE FROM shared_use WHERE did = ?1 AND key = ?2",
"DELETE FROM star WHERE repo_did = ?1 AND repo_key = ?2",
@@ -894,6 +898,7 @@ const PURGE: &[&str] = &[
"DELETE FROM follow WHERE did = ?1",
"DELETE FROM reaction WHERE did = ?1 -- scans: reaction, once, as an account is forgotten",
"DELETE FROM release WHERE did = ?1",
"DELETE FROM site WHERE uri >= ?2 AND uri < ?3",
"DELETE FROM shared WHERE did = ?1",
"DELETE FROM shared_use WHERE did = ?1",
"DELETE FROM theme WHERE did = ?1",
@@ -1249,6 +1254,18 @@ impl Index {
text
],
)
} else if collection == nsid::SITE {
// A public repository's site, keyed by the repository's key:
// one that is not a site's record is passed by, as is one in a
// space, which has no site yet, and one with a file whose hash
// is not one, as the hash names the file it is kept in.
let site = serde_json::from_value::<SiteRecord>(value.clone());
let hashed = |s: SiteRecord| s.files.iter().all(|f| valid_sha256(&f.sha256));
let kept = site.is_ok_and(hashed) && space.is_empty();
db.execute(
"INSERT OR REPLACE INTO site SELECT ?1, ?2, ?3, ?4 WHERE ?5",
params![uri, did, rkey, text, kept],
)
} else if collection == nsid::RELEASE {
// Kept by the owner and the key of the repository it is of, as
// its record names it: one that is not a release, by its names,
@@ -1518,6 +1535,7 @@ impl Index {
db.execute("DELETE FROM minutes WHERE uri = ?1", [&uri])?;
db.execute("DELETE FROM runner_key WHERE uri = ?1", [&uri])?;
db.execute("DELETE FROM release WHERE uri = ?1", [&uri])?;
db.execute("DELETE FROM site WHERE uri = ?1", [&uri])?;
db.execute("DELETE FROM shared WHERE uri = ?1", [&uri])?;
db.execute(
"DELETE FROM profile_record WHERE did = ?1 AND collection = ?2 AND rkey = ?3",
@@ -1603,6 +1621,17 @@ impl Index {
Ok(rows.filter_map(Result::ok).flatten().collect())
}
/// The site of `did`'s repository `key`, as its record was last
/// indexed: none when it has none, or it is taken down.
pub fn site(&self, did: &str, key: &str) -> rusqlite::Result<Option<String>> {
let db = self.readers.take(&self.db);
let uri = format!("at://{did}/{}/{key}", nsid::SITE);
let mut stmt = db.prepare_cached(
"SELECT value FROM site WHERE uri = ?1 AND uri NOT IN down AND did NOT IN down",
)?;
stmt.query_row([&uri], |row| row.get(0)).optional()
}
/// How many accounts starred `did`'s repository with `key`, leaving
/// out those taken down.
pub fn star_count(&self, did: &str, key: &str) -> rusqlite::Result<u64> {
@@ -6966,4 +6995,34 @@ mod tests {
assert!(Index::open(&dir.path().join("i.db")).is_ok());
assert!(Index::open(dir.path()).is_err());
}
#[test]
fn sites_are_kept_by_their_repository() {
let dir = tempfile::tempdir().unwrap();
let index = Index::open(&dir.path().join("i.db")).unwrap();
let file =
|hash: &str| json!({"path": "index.html", "size": 1, "sha256": hash, "parts": []});
let hash = "0".repeat(64);
let site = json!({"files": [file(&hash)], "createdAt": "2026-11-03T00:00:00Z"});
index.put("did:a", nsid::SITE, "k", &site).unwrap();
let kept = index.site("did:a", "k").unwrap().unwrap();
assert_eq!(serde_json::from_str::<Value>(&kept).unwrap(), site);
// Not a site's record, or one in a space: passed by.
index
.put("did:a", nsid::SITE, "j", &json!({"files": 1}))
.unwrap();
let space = "ats://did:a/dev.knotless.space/s";
index
.put_from_space(space, "did:a", nsid::SITE, "i", &site)
.unwrap();
assert_eq!(index.site("did:a", "j").unwrap(), None);
// A hash that would name a path outside where files are kept.
let out = format!("{:.<64}", "../../../../etc/passwd");
let bad = json!({"files": [file(&out)], "createdAt": "2026-11-03T00:00:00Z"});
index.put("did:a", nsid::SITE, "h", &bad).unwrap();
assert_eq!(index.site("did:a", "h").unwrap(), None);
assert_eq!(index.site("did:a", "i").unwrap(), None);
index.delete("did:a", nsid::SITE, "k").unwrap();
assert_eq!(index.site("did:a", "k").unwrap(), None);
}
}
crates/knotless-serve/src/lib.rs+30−1
@@ -41,6 +41,7 @@ pub mod private;
pub mod releases;
mod secrets;
pub mod shared;
pub mod sites;
mod smart;
pub mod spaces;
pub mod status;
@@ -858,6 +859,7 @@ fn indexed_collections() -> impl Iterator<Item = &'static str> {
knotless::records::nsid::FOLLOW,
knotless::records::nsid::REACTION,
knotless::records::nsid::RELEASE,
knotless::records::nsid::SITE,
knotless::records::nsid::THEME,
knotless::records::nsid::ORG,
knotless::records::nsid::ORG_MEMBER,
@@ -1303,6 +1305,9 @@ pub struct Options {
pub oauth_url: Option<String>,
/// Where code search keeps its Zoekt shards, if it is on.
pub code_index: Option<std::path::PathBuf>,
/// The domain repositories' sites are answered on, each owner at a
/// label of it, as `knotless.page`: none answers none.
pub pages_domain: Option<String>,
}
/// What `--private`, which is gone, says instead.
@@ -1335,6 +1340,7 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> {
sign_up_pds: gateway::SIGN_UP_PDS.into(),
oauth_url: None,
code_index: None,
pages_domain: None,
};
let mut it = args.iter().skip(1);
while let Some(flag) = it.next() {
@@ -1353,6 +1359,7 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> {
"--firehose" => opts.firehose = Some(value()?).filter(|v| !v.is_empty()),
"--jetstream" => opts.jetstream = Some(value()?).filter(|v| !v.is_empty()),
"--public-url" => opts.public_url = Some(value()?),
"--pages-domain" => opts.pages_domain = Some(value()?).filter(|v| !v.is_empty()),
"--index" => opts.index = Some(value()?.into()),
"--backfill" => opts.backfill = Some(value()?).filter(|v| !v.is_empty()),
"--relay" => opts.relay = Some(value()?),
@@ -1406,7 +1413,7 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> {
"--code-index" => opts.code_index = Some(value()?.into()),
other => {
return Err(format!(
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--oauth-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--relay <https://relay>] [--labeler <did>]... [--default-runner <did>] [--client-minimum <version> [--client-minimum-reason <text>]] [--web <http://app>] [--releases <dir or knotless://repo>] [--stale <secs>] [--usage <file>] [--logs <file> [--logs-keep <lines>]] [--check-index <file>] [--metrics <addr>] [--bots <dir>] [--sign-up-pds <https://pds>] [--code-index <dir>]"
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--oauth-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--relay <https://relay>] [--labeler <did>]... [--default-runner <did>] [--client-minimum <version> [--client-minimum-reason <text>]] [--web <http://app>] [--releases <dir or knotless://repo>] [--stale <secs>] [--usage <file>] [--logs <file> [--logs-keep <lines>]] [--check-index <file>] [--metrics <addr>] [--bots <dir>] [--sign-up-pds <https://pds>] [--code-index <dir>] [--pages-domain <domain>]"
));
}
}
@@ -1440,6 +1447,11 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> {
if opts.public_url.is_none() && opts.bots.is_some() {
anyhow::bail!("--bots takes --public-url, where bots log in");
}
if opts.public_url.is_none() && opts.pages_domain.is_some() {
anyhow::bail!(
"--pages-domain takes --public-url, where a site's owner is sent from its root"
);
}
let gateway = match &opts.public_url {
Some(url) => {
let mut gw = gateway::Gateway::new(url).map_err(anyhow::Error::msg)?;
@@ -1538,13 +1550,20 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> {
.map(|url| mcp_routes(mcp_ctx, url, index, addr, usage.clone()));
let mcp = mcp.transpose()?.unwrap_or_default();
let registry = oci::routes(app.ctx.clone(), opts.public_url.clone());
let home = opts.public_url.clone().unwrap_or_default();
let releases = releases::routes(opts.releases, opts.public_url, app.ctx.clone());
let stopping = app.live.stopping.clone();
let sites = opts
.pages_domain
.map(|d| sites::Sites::new(app.clone(), d, home));
let mut routes = router(app)
.merge(releases)
.merge(registry)
.merge(mcp)
.merge(pages);
if let Some(sites) = sites {
routes = sites::hosted(routes, sites);
}
if let Some(usage) = usage.clone() {
tokio::spawn(usage::flushing(usage.clone(), usage::FLUSH));
routes = usage::counted(routes, usage);
@@ -1706,4 +1725,14 @@ mod tests {
let down = AppError::upstream(anyhow::anyhow!("resolver down"));
assert_eq!(down.0, StatusCode::BAD_GATEWAY);
}
// A collection a backfill reads and the firehose passes by is indexed
// once and never again: each list names it, or the index goes stale.
#[test]
fn the_firehose_keeps_every_indexed_collection() {
for c in indexed_collections() {
let kept = knotless::jetstream::knotless(c);
assert!(kept, "{c} is backfilled but not kept live");
}
}
}
crates/knotless-serve/src/plans.rs+2−1
@@ -32,7 +32,7 @@ pub const ENV: &str = "KNOTLESS_PLANS";
/// The tables that grow with what people do, as many rows as prod might
/// hold: a scan of one is a scan of the network. The rest are said to be
/// small, as a scan of them is.
pub const GROWING: [&str; 18] = [
pub const GROWING: [&str; 19] = [
"forge",
"check_run",
"comment",
@@ -45,6 +45,7 @@ pub const GROWING: [&str; 18] = [
"space_record",
"profile_record",
"release",
"site",
"reviewer",
"assigned",
"theme",
crates/knotless-serve/src/sites.rsnew+476−0
476 lines changed: not loaded until asked for.
crates/knotless-serve/tests/serve.rs+13−0
@@ -292,6 +292,14 @@ fn binary_lifecycle() {
.unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("--public-url"));
// So are a site's owners sent from its root.
let out = s
.command(exe, s.dir.path())
.args(["--pages-domain", "knotless.page"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("--pages-domain takes --public-url"));
std::fs::create_dir_all(s.path("releases")).unwrap();
std::fs::write(s.path("releases/latest"), "0.1.0\n").unwrap();
@@ -332,6 +340,8 @@ fn binary_lifecycle() {
"100000",
"--code-index",
&s.path("code").to_string_lossy(),
"--pages-domain",
"knotless.page",
"--client-minimum",
"0.1.0",
"--client-minimum-reason",
@@ -618,6 +628,9 @@ fn arguments() {
assert_eq!(o.sign_up_pds, "https://pds.example");
assert!(args(&["--sign-up-pds"]).is_err());
assert_eq!(o.oauth_url, None);
assert_eq!(o.pages_domain, None);
let o = args(&["--pages-domain", "knotless.page"]).unwrap();
assert_eq!(o.pages_domain.as_deref(), Some("knotless.page"));
let o = args(&["--oauth-url", "https://gateway.example"]).unwrap();
assert_eq!(o.oauth_url.as_deref(), Some("https://gateway.example"));
}
crates/knotless-serve/tests/sites.rsnew+168−0
@@ -0,0 +1,168 @@
//! Repositories' websites, deployed with `knotless site` and answered on
//! their own domain by an appview that follows the reference PDS.
#[path = "../../knotless/tests/common/mod.rs"]
mod common;
use common::*;
use knotless_serve::index::Index;
use knotless_serve::sites::{Sites, hosted};
use knotless_serve::{App, router};
use std::time::Duration;
const DOMAIN: &str = "knotless.page";
const HOME: &str = "https://pages.example";
/// What a request to `base` as `host` got: its status, headers and body.
fn get(
base: &str,
host: &str,
path: &str,
headers: &[(&str, &str)],
) -> (u16, reqwest::header::HeaderMap, String) {
let client = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let mut req = client
.get(format!("{base}{path}"))
.header("x-forwarded-host", host);
for (name, value) in headers {
req = req.header(*name, *value);
}
let res = req.send().unwrap();
let (status, headers) = (res.status().as_u16(), res.headers().clone());
(status, headers, res.text().unwrap())
}
#[test]
fn sites_are_served_on_their_domain() {
let mut s = Sandbox::new();
let handle = s.account.handle.clone();
let did = s.account.did.clone();
s.set("KNOTLESS_IDENTIFIER", &handle);
s.set("KNOTLESS_CHUNK_SIZE", "1000");
let rt = tokio::runtime::Runtime::new().unwrap();
let listener = rt
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let index = Index::open(&s.path("index.db")).unwrap();
let app = App::build(ctx(&s), Duration::ZERO, None, Some(index));
let sites = Sites::new(app.clone(), DOMAIN.into(), format!("{HOME}/"));
let routes = hosted(router(app.clone()), sites);
rt.spawn(async move { axum::serve(listener, routes).await.unwrap() });
let ws = net().pds.replace("http://", "ws://");
rt.spawn(knotless_serve::firehose::watch(app.clone(), ws));
let url = s.url("site");
let work = s.work("w", 1);
s.git(&work, &["push", "-q", &url, "main"]);
s.git(&work, &["push", "-q", &s.url("bare"), "main"]);
let dist = s.path("dist");
let write = |path: &str, bytes: &[u8]| {
let at = dist.join(path);
std::fs::create_dir_all(at.parent().unwrap()).unwrap();
std::fs::write(at, bytes).unwrap();
};
let script: String = (0..400).map(|i| format!("let a{i} = {i};\n")).collect();
write("index.html", b"<h1>home</h1>");
write("app.js", script.as_bytes());
write("docs/index.html", b"docs");
write("404.html", b"gone");
write("data.bin", b"\x00\x01");
let dir = dist.to_string_lossy().into_owned();
let deploy = ["site", &url, &dir, "--not-found", "404.html"];
let (code, out) = s.cli(&deploy, b"");
assert_eq!(code, 0, "{out}");
let host = format!("{}.{DOMAIN}", handle.replace('-', "--").replace('.', "-"));
let plc = did.strip_prefix("did:plc:").unwrap();
let by_did = format!("{plc}.{DOMAIN}");
wait_until(|| get(&base, &host, "/site/", &[]).0 == 200);
// A page, as its record says, with its hash as its ETag.
let (status, headers, body) = get(&base, &host, "/site/", &[]);
assert_eq!((status, body.as_str()), (200, "<h1>home</h1>"));
assert_eq!(headers["content-type"], "text/html; charset=utf-8");
assert_eq!(headers["x-content-type-options"], "nosniff");
assert_eq!(headers["cache-control"], "public, max-age=60");
let etag = headers["etag"].to_str().unwrap().to_string();
let (status, _, body) = get(&base, &host, "/site/", &[("if-none-match", &etag)]);
assert_eq!((status, body.as_str()), (304, ""));
// Joined from its parts, and typed by its extension, or as bytes.
let (status, headers, body) = get(&base, &by_did, "/site/app.js", &[]);
assert_eq!((status, body), (200, script));
assert_eq!(headers["content-type"], "text/javascript; charset=utf-8");
let (_, headers, _) = get(&base, &host, "/site/data.bin", &[]);
assert_eq!(headers["content-type"], "application/octet-stream");
// Directories, with and without their `/`.
let location = |path: &str, host: &str| {
let (status, headers, _) = get(&base, host, path, &[]);
assert_eq!(status, 302, "{path}");
headers["location"].to_str().unwrap().to_string()
};
assert_eq!(location("/site", &host), "/site/");
assert_eq!(location("/site/docs", &host), "docs/");
assert_eq!(get(&base, &host, "/site/docs/", &[]).2, "docs");
// The owner's root is their page.
assert_eq!(location("/", &host), format!("{HOME}/{handle}"));
assert_eq!(location("/", &by_did), format!("{HOME}/{did}"));
// What is not there: the site's 404 page, or a plain one.
let (status, _, body) = get(&base, &host, "/site/nope.js", &[]);
assert_eq!((status, body.as_str()), (404, "gone"));
for (host, path) in [
(host.as_str(), "/bare/"),
(host.as_str(), "/nothing/"),
(host.as_str(), "/site/%ff"),
// Not redirected to `/\evil.example/`, which a browser reads as
// another host.
(host.as_str(), "/%5Cevil.example"),
("short.knotless.page", "/site/"),
("nobody-test.knotless.page", "/site/"),
] {
let (status, _, body) = get(&base, host, path, &[]);
assert_eq!(
(status, body.as_str()),
(404, "no such site\n"),
"{host}{path}"
);
}
// Only reads, and only on its domain: anything else is the server's.
let res = reqwest::blocking::Client::new()
.post(format!("{base}/site/"))
.header("x-forwarded-host", &host)
.send()
.unwrap();
assert_eq!(res.status().as_u16(), 405);
assert_eq!(res.headers()["allow"], "GET, HEAD");
assert_eq!(get(&base, "localhost", "/healthz", &[]).2, "ok");
// Deployed again as a single-page app: its routes are its page, and a
// script still missing is not.
write("index.html", b"<h1>app</h1>");
let again = ["site", &url, &dir, "--fallback", "index.html"];
assert_eq!(s.cli(&again, b"").0, 0);
wait_until(|| get(&base, &host, "/site/", &[]).2 == "<h1>app</h1>");
let (status, _, body) = get(&base, &host, "/site/settings/me", &[]);
assert_eq!((status, body.as_str()), (200, "<h1>app</h1>"));
assert_eq!(get(&base, &host, "/site/gone.js", &[]).0, 404);
// A file that is not what its record says is not served.
let (rkey, mut record) = s.records("dev.knotless.site").remove(0);
let files = record["files"].as_array_mut().unwrap();
let docs = files
.iter_mut()
.find(|f| f["path"] == "docs/index.html")
.unwrap();
docs["sha256"] = serde_json::json!("0".repeat(64));
s.put_record("dev.knotless.site", &rkey, record);
wait_until(|| get(&base, &host, "/site/docs/", &[]).0 == 502);
// Taken down, it is gone.
assert_eq!(s.cli(&["site", &url, "--down"], b"").0, 0);
wait_until(|| get(&base, &host, "/site/", &[]).0 == 404);
}
deploy/deploy.sh+1−0
@@ -238,6 +238,7 @@ fi
[ -z "${KNOTLESS_ADMIN_DIDS:-}" ] || echo "KNOTLESS_ADMIN_DIDS=$KNOTLESS_ADMIN_DIDS"
[ -z "${KNOTLESS_BOT_DIDS:-}" ] || echo "KNOTLESS_BOT_DIDS=$KNOTLESS_BOT_DIDS"
[ -z "${KNOTLESS_LOG:-}" ] || echo "KNOTLESS_LOG=$KNOTLESS_LOG"
[ -z "${KNOTLESS_PAGES_DOMAIN:-}" ] || echo "KNOTLESS_PAGES_DOMAIN=$KNOTLESS_PAGES_DOMAIN"
[ -z "$PROFILES" ] || echo "COMPOSE_PROFILES=$PROFILES"
if [ -n "$TRIAGE_REPOS" ]; then
echo "KNOTLESS_TRIAGE_REPOS=$TRIAGE_REPOS"
deploy/docker-compose.yml+5−0
@@ -66,6 +66,11 @@ services:
# `knotless` package, which the release workflow publishes.
- --releases
- knotless://chadtmiller.com/knotless
# Repositories' sites, on a domain of their own (knotless.page), when
# KNOTLESS_PAGES_DOMAIN names one; setup.sh's Caddy holds its
# certificate. Empty answers none.
- --pages-domain
- ${KNOTLESS_PAGES_DOMAIN:-}
# /metrics and PUT /log, inside the container alone: read them
# with docker exec (deploy/README.md, Logs).
- --metrics
deploy/local.env.example+5−0
deploy/README.md+30−0
deploy/setup.sh+59−4
docs/design/pages.md+2−2
docs/design/serve.md+9−0
docs/reference/cli.md+1−0