mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 12:54:03 +00:00
OpenInstall 0.1: one idempotent bin/install.sh that puts an app into service (#221)
* OpenInstall 0.1: one idempotent bin/install.sh that puts an app into service A repository carries bin/install.sh and, when the defaults are not right, bin/install.conf. Run on a box from a checkout it installs the runtime and a local Postgres or Redis, builds, writes a systemd unit and an nginx site with TLS, restarts and health-checks the app. Phases setup, build, activate and status; exit 0, 1 or 3; never overwrites a file without its marker line; secrets only in STATE_DIR/app.env. sh1pt's deploy-ssh install.sh is the reference script and `sh1pt ship --target deploy-ssh` the reference deployer. Registered in the process family, with a landing page at /openinstall and a contract test holding the page's tables to the spec. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(openinstall): describe DATABASE_URL without a credentials-shaped URL ThreatCrush flagged the placeholder postgres URL in the worked example. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f204bc266d
commit
e62c546a1e
6 changed files with 577 additions and 2 deletions
63
apps/logicsrc-web/contract/openinstall.contract.test.ts
Normal file
63
apps/logicsrc-web/contract/openinstall.contract.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { readDoc } from "../src/lib/docs";
|
||||
import { CONF, EXIT_CODES, PHASES, SETTINGS } from "../src/app/openinstall/data";
|
||||
|
||||
// The landing page restates the spec's tables. These tests keep the two, and
|
||||
// the spec's own worked example, from drifting apart.
|
||||
|
||||
const doc = readDoc("openinstall") ?? "";
|
||||
|
||||
/** The first-column `code` cells of the table under a `## heading`. */
|
||||
function tableKeys(heading: string): string[] {
|
||||
const start = doc.indexOf(`\n## ${heading}\n`);
|
||||
expect(start, `docs/openinstall.md has a ## ${heading} section`).toBeGreaterThan(-1);
|
||||
const next = doc.indexOf("\n## ", start + 1);
|
||||
const section = doc.slice(start, next === -1 ? undefined : next);
|
||||
return section
|
||||
.split("\n")
|
||||
.map((line) => line.match(/^\| `([^`]+)` \|/))
|
||||
.filter((m): m is RegExpMatchArray => m !== null)
|
||||
.map((m) => m[1]);
|
||||
}
|
||||
|
||||
function confKeys(conf: string): string[] {
|
||||
return conf
|
||||
.split("\n")
|
||||
.filter((line) => line && !line.startsWith("#"))
|
||||
.map((line) => line.slice(0, line.indexOf("=")));
|
||||
}
|
||||
|
||||
describe("OpenInstall: the spec and its landing page agree", () => {
|
||||
it("lists the same settings, in the same order", () => {
|
||||
expect(SETTINGS.map(([key]) => key)).toEqual(tableKeys("Settings"));
|
||||
});
|
||||
|
||||
it("lists the same phases and exit codes", () => {
|
||||
expect(PHASES.map(([name]) => name)).toEqual(tableKeys("Phases"));
|
||||
expect(EXIT_CODES.map(([code]) => code)).toEqual(tableKeys("Exit codes"));
|
||||
expect(tableKeys("Exit codes")).toEqual(["0", "1", "3"]);
|
||||
});
|
||||
|
||||
it("uses only documented settings in the worked example, which the page shows verbatim", () => {
|
||||
const documented = new Set(tableKeys("Settings"));
|
||||
const fence = doc.match(/```\n(# bin\/install\.conf[^`]*?)```/);
|
||||
expect(fence, "the worked example's install.conf").not.toBeNull();
|
||||
const example = fence![1].trim();
|
||||
expect(example).toBe(CONF);
|
||||
for (const key of confKeys(example)) expect(documented.has(key), key).toBe(true);
|
||||
});
|
||||
|
||||
it("numbers its conformance rules without gaps", () => {
|
||||
const numbers = [...doc.matchAll(/^### (\d+)\. /gm)].map((m) => Number(m[1]));
|
||||
expect(numbers.length).toBeGreaterThanOrEqual(10);
|
||||
expect(numbers).toEqual(numbers.map((_, i) => i + 1));
|
||||
});
|
||||
|
||||
it("names the ownership marker the reference script writes", () => {
|
||||
expect(doc).toContain("`# managed by bin/install.sh`");
|
||||
});
|
||||
|
||||
it("has no em dashes", () => {
|
||||
expect(doc).not.toContain(String.fromCharCode(0x2014));
|
||||
});
|
||||
});
|
||||
|
|
@ -18,7 +18,8 @@ describe.each([
|
|||
{ slug: "openwall", name: "OpenWall", family: "people" },
|
||||
{ slug: "openobject", name: "OpenObject", family: "catalogs" },
|
||||
{ slug: "openslice", name: "OpenSlice", family: "catalogs" },
|
||||
{ slug: "openstack", name: "OpenStack.md", family: "catalogs" }
|
||||
{ slug: "openstack", name: "OpenStack.md", family: "catalogs" },
|
||||
{ slug: "openinstall", name: "OpenInstall", family: "process" }
|
||||
])("$name public discovery", ({ slug, name, family }) => {
|
||||
it("serves the specification through its family and docs index", () => {
|
||||
expect(familyOfSpec(slug)?.slug).toBe(family);
|
||||
|
|
|
|||
48
apps/logicsrc-web/src/app/openinstall/data.ts
Normal file
48
apps/logicsrc-web/src/app/openinstall/data.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// The facts the /openinstall landing page shows, kept apart from page.tsx so
|
||||
// contract/openinstall.contract.test.ts can hold them against docs/openinstall.md.
|
||||
|
||||
export const PHASES: Array<[string, string]> = [
|
||||
["setup", "STATE_DIR and an empty app.env, the runtime when it is missing, a local Postgres database and Redis when asked for, with DATABASE_URL and REDIS_URL recorded in db.env."],
|
||||
["build", "Dependencies from the lockfile, then the build, in SRC_DIR, with the secrets in the environment. Nothing outside the checkout."],
|
||||
["activate", "run.sh and the systemd unit, a restart, the health check, and only then the nginx site, nginx -t, the reload and a certificate."],
|
||||
["status", "key=value lines: app, runtime, src, app_dir, state, unit, active, health, domains. Changes nothing."],
|
||||
["all", "No argument: setup, build, activate, stopping at the first failure."]
|
||||
];
|
||||
|
||||
/** Every setting, in the order docs/openinstall.md lists them, with its default. */
|
||||
export const SETTINGS: Array<[string, string]> = [
|
||||
["APP", "the repository directory's name"],
|
||||
["RUNTIME", "auto, from the lockfiles"],
|
||||
["INSTALL_CMD", "auto, the frozen-lockfile install"],
|
||||
["BUILD_CMD", "<pm> run build, when there is one"],
|
||||
["START_CMD", "<pm> run start"],
|
||||
["PORT", "3000"],
|
||||
["HEALTH_PATH", "/"],
|
||||
["HEALTH_TIMEOUT", "120"],
|
||||
["DOMAINS", "empty: no nginx site"],
|
||||
["TLS", "1, Let's Encrypt"],
|
||||
["TLS_EMAIL", "none"],
|
||||
["STATIC_DIR", "dist"],
|
||||
["SPA", "0"],
|
||||
["POSTGRES", "0"],
|
||||
["REDIS", "0"],
|
||||
["MAX_BODY", "100m"],
|
||||
["SRC_DIR", "the repository the script is in"],
|
||||
["APP_DIR", "SRC_DIR"],
|
||||
["STATE_DIR", "~/.local/share/<APP>"],
|
||||
["UNIT", "APP"]
|
||||
];
|
||||
|
||||
export const EXIT_CODES: Array<[string, string]> = [
|
||||
["0", "The phase did what it says. status always exits 0."],
|
||||
["1", "An error: a bad setting, a missing tool, a failed build, no root, a file that is not ours, an nginx config that did not test."],
|
||||
["3", "activate restarted the service and the health check did not pass. nginx was not touched. A deployer rolls back."]
|
||||
];
|
||||
|
||||
export const CONF = `# bin/install.conf: committed, no secrets
|
||||
APP=ledger
|
||||
PORT=3000
|
||||
HEALTH_PATH=/healthz
|
||||
DOMAINS="ledger.example.com www.ledger.example.com"
|
||||
TLS_EMAIL=ops@example.com
|
||||
POSTGRES=1`;
|
||||
200
apps/logicsrc-web/src/app/openinstall/page.tsx
Normal file
200
apps/logicsrc-web/src/app/openinstall/page.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { specMetadata } from "@/lib/page-meta";
|
||||
import { SiteShell } from "@/components/site-shell";
|
||||
import { mono, pre, table, td, th } from "../openontology/ui";
|
||||
import { CONF, EXIT_CODES, PHASES, SETTINGS } from "./data";
|
||||
|
||||
export const metadata: Metadata = specMetadata(
|
||||
"/openinstall",
|
||||
"OpenInstall is one idempotent bin/install.sh in every repository. Run on a server from a checkout, it installs the runtime and a local database, builds, writes a systemd unit and an nginx site with TLS, restarts and health-checks the app, and exits 0, 1 or 3 so a deployer knows whether to roll back."
|
||||
);
|
||||
|
||||
const USAGE = `git clone https://git.example.com/acme/ledger.git
|
||||
cd ledger
|
||||
./bin/install.sh # setup + build + activate
|
||||
./bin/install.sh status # key=value lines`;
|
||||
|
||||
const LAYOUT = `<root>/repo.git a mirror of the repository
|
||||
<root>/releases/<id> one directory per release
|
||||
<root>/current -> releases/<id> what the unit runs (APP_DIR)
|
||||
<root>/shared/ STATE_DIR: app.env, db.env, run.sh`;
|
||||
|
||||
const RULES: Array<[string, string]> = [
|
||||
["Idempotent", "Every step checks before it acts. A file is written only when its content differs, and systemd and nginx reload only when a file they read changed. The second run changes no file; activate still restarts the service so a fresh build takes effect."],
|
||||
["Owns only its own files", "Every unit and site it writes starts with # managed by bin/install.sh. A file already there without that line is never overwritten: the script exits 1 and says so."],
|
||||
["No secrets in the repository", "bin/install.conf is committed and holds settings only. Secrets live in STATE_DIR/app.env, mode 0600, written by a deployer from a vault or by a person."],
|
||||
["Up before it is routed to", "activate restarts the service and waits for a 2xx or 3xx from the health path on loopback before it touches nginx. An nginx change that fails nginx -t is put back."],
|
||||
["Build, run and state apart", "SRC_DIR is built, APP_DIR is run, STATE_DIR is kept. A deployer builds in releases/<id>, runs from a current symlink, and rolls back by flipping it."]
|
||||
];
|
||||
|
||||
export default function OpenInstallPage(): ReactNode {
|
||||
return (
|
||||
<SiteShell active="OpenInstall">
|
||||
<div className="band">
|
||||
<div className="section-head">
|
||||
<p className="eyebrow">LogicSRC standards surface</p>
|
||||
<h2>OpenInstall</h2>
|
||||
<p>
|
||||
One script every repository carries at <code style={mono}>bin/install.sh</code>. Run
|
||||
on a server from a checkout, it puts the app into service on that box: the runtime
|
||||
and a local database, the build, a systemd unit, an nginx site with a certificate, a
|
||||
restart and a health check. Run it twice and the second run changes nothing.
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ color: "#41505d" }}>
|
||||
Every app on a plain server needs the same dozen steps, and most repositories keep them
|
||||
in a README section that was true once or a script on one laptop. A new box means doing
|
||||
them from memory, and a second run of a half-written script means a duplicate database
|
||||
role or an nginx site with two server blocks. OpenInstall fixes the place, the verbs,
|
||||
the settings file and the exit codes, so a person can run any repository's copy
|
||||
after <code style={mono}>git clone</code> and a deployer can run it without reading it
|
||||
first.
|
||||
</p>
|
||||
<p style={{ color: "#5b6b7a" }}>
|
||||
Status: 0.1. Bare metal or a VPS, systemd and nginx; no Kubernetes, no platform. The
|
||||
reference script is sh1pt's{" "}
|
||||
<code style={mono}>packages/targets/deploy-ssh/bin/install.sh</code>.
|
||||
</p>
|
||||
<pre style={pre}>{USAGE}</pre>
|
||||
</div>
|
||||
|
||||
<div className="band">
|
||||
<div className="section-head">
|
||||
<h2>Phases</h2>
|
||||
<p>
|
||||
The first argument. Root, or passwordless sudo, is needed for apt, Postgres, systemd and
|
||||
nginx; the service itself runs as the user who ran the script.
|
||||
</p>
|
||||
</div>
|
||||
<table style={table}>
|
||||
<tbody>
|
||||
{PHASES.map(([name, what]) => (
|
||||
<tr key={name}>
|
||||
<td style={td}>
|
||||
<code style={mono}>{name}</code>
|
||||
</td>
|
||||
<td style={td}>{what}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="band">
|
||||
<div className="section-head">
|
||||
<h2>The rules that make it safe to run again</h2>
|
||||
</div>
|
||||
<table style={table}>
|
||||
<tbody>
|
||||
{RULES.map(([rule, meaning]) => (
|
||||
<tr key={rule}>
|
||||
<td style={td}>
|
||||
<strong>{rule}</strong>
|
||||
</td>
|
||||
<td style={td}>{meaning}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="band">
|
||||
<div className="section-head">
|
||||
<h2>Settings</h2>
|
||||
<p>
|
||||
<code style={mono}>bin/install.conf</code>, <code style={mono}>KEY=value</code> lines,
|
||||
committed. The environment wins. A repository with a lockfile and{" "}
|
||||
<code style={mono}>build</code> and <code style={mono}>start</code> scripts needs no
|
||||
file at all. A bun app with Postgres and a domain:
|
||||
</p>
|
||||
</div>
|
||||
<pre style={pre}>{CONF}</pre>
|
||||
<table style={table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>key</th>
|
||||
<th style={th}>default</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SETTINGS.map(([key, dflt]) => (
|
||||
<tr key={key}>
|
||||
<td style={td}>
|
||||
<code style={mono}>{key}</code>
|
||||
</td>
|
||||
<td style={td}>{dflt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="band">
|
||||
<div className="section-head">
|
||||
<h2>Exit codes</h2>
|
||||
<p>A deployer reads nothing else to decide whether a release went out.</p>
|
||||
</div>
|
||||
<table style={table}>
|
||||
<tbody>
|
||||
{EXIT_CODES.map(([code, meaning]) => (
|
||||
<tr key={code}>
|
||||
<td style={td}>
|
||||
<code style={mono}>{code}</code>
|
||||
</td>
|
||||
<td style={td}>{meaning}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="band">
|
||||
<div className="section-head">
|
||||
<h2>Deployers</h2>
|
||||
<p>
|
||||
<code style={mono}>sh1pt ship --target deploy-ssh</code> takes the source from any git
|
||||
host the box can read (GitHub, GitLab, Codeberg, a self-hosted forge) or pushes it with
|
||||
rsync, writes <code style={mono}>app.env</code> from the vault, runs the
|
||||
repository's script, or a bundled copy when there is none, and flips{" "}
|
||||
<code style={mono}>current</code> back when <code style={mono}>activate</code> exits
|
||||
non-zero.
|
||||
</p>
|
||||
</div>
|
||||
<pre style={pre}>{LAYOUT}</pre>
|
||||
</div>
|
||||
|
||||
<div className="band">
|
||||
<div className="section-head">
|
||||
<h2>Not</h2>
|
||||
</div>
|
||||
<p style={{ color: "#41505d" }}>
|
||||
Not a provisioner of the box: no users, firewall, ssh or kernel. Not container
|
||||
orchestration: one box, systemd and nginx. Not a package manager: it calls the one the
|
||||
lockfile names. Not a CI system, not a secret store, not a release manager. No{" "}
|
||||
<code style={mono}>.well-known</code> file: the script lives in the repository and is read
|
||||
by whoever has the checkout.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="band">
|
||||
<div className="section-head">
|
||||
<h2>Where everything lives</h2>
|
||||
</div>
|
||||
<ul style={{ color: "#41505d", lineHeight: 1.9, paddingLeft: "1.1rem" }}>
|
||||
<li>
|
||||
<Link href="/docs/openinstall">Specification</Link>: the phases, the settings and their
|
||||
defaults, the state files, exit codes, twelve conformance rules, what a deployer owes
|
||||
the script, and a worked example
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/openstack">OpenStack.md</Link>, whose Hosting section can name the script;{" "}
|
||||
<Link href="/openserver">OpenServer</Link>, the box a project buys before the script
|
||||
runs on it
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ export const FAMILIES: Family[] = [
|
|||
name: "Agents and process",
|
||||
line: "How agents coordinate, settle, stream, and how the software that serves them gets built",
|
||||
blurb:
|
||||
"The lifecycle for building software when agents work in parallel and CI is the only gate, the requirement document an agent can execute, the settlement and proof layer under a peer-to-peer swarm, a lossless byte-stream envelope, the five nouns a shared ontology needs, and the record an agent session carries about who spawned it and under what ceiling.",
|
||||
"The lifecycle for building software when agents work in parallel and CI is the only gate, the requirement document an agent can execute, the settlement and proof layer under a peer-to-peer swarm, a lossless byte-stream envelope, the five nouns a shared ontology needs, the record an agent session carries about who spawned it and under what ceiling, and the one script that puts an application into service on the box it runs on.",
|
||||
specs: [
|
||||
s("asdlc", "ASDLC", "The Agentic Software Development Lifecycle: nine phases, four conformance levels and the ratchet rule"),
|
||||
s("openabtest", "OpenABTest", "Portable experiments with sticky assignments, distinct exposure and conversion events, and reconciled profit accounting", { landing: undefined, status: "draft" }),
|
||||
|
|
@ -127,6 +127,7 @@ export const FAMILIES: Family[] = [
|
|||
s("openstream", "OpenStream", "A lossless byte-stream relay envelope, with benchmark reports per release", { landing: undefined }),
|
||||
s("openontology", "OpenOntology", "Five nouns for a shared ontology, with governance and interoperability notes"),
|
||||
s("openfleet", "OpenFleet", "Agents under a human: the record a session carries about who spawned it, for what and under what ceiling, and the ledger its sysop reads"),
|
||||
s("openinstall", "OpenInstall", "One idempotent bin/install.sh in every repository that puts the app into service on the box it runs on: runtime, database, build, systemd, nginx and TLS, a health check, and exit codes a deployer can roll back on"),
|
||||
s("openspec", "OpenSpec.dev comparison", "How LogicSRC compares with OpenSpec.dev, and the compatibility mode", { doc: "/docs/openspec-comparison" })
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue