Merge branch 'flow/distribution/P4.1' into distribution-plan
This commit is contained in:
52
src/dist.sx
52
src/dist.sx
@@ -7,6 +7,7 @@
|
||||
// dist ci publish local publish pipeline (P3.4a/b) — see publish.sx
|
||||
// dist release promote point a channel at a release (P3.5) — see release/ops.sx
|
||||
// dist release rollback channel pointer to the previous release (P3.5)
|
||||
// dist server run read-only HTTP API over the store (P4.1) — see server/distd.sx
|
||||
//
|
||||
// EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with
|
||||
// `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing
|
||||
@@ -29,6 +30,7 @@
|
||||
jout :: #import "json_out.sx";
|
||||
pl :: #import "publish/publish.sx";
|
||||
ops :: #import "release/ops.sx";
|
||||
srv :: #import "server/distd.sx";
|
||||
|
||||
// Direct stderr writer (fd 2), so human help/usage/progress never lands on
|
||||
// stdout's data stream. `out` (std builtin) targets stdout (fd 1).
|
||||
@@ -43,7 +45,7 @@ emit_human :: (s: string, json_mode: bool) {
|
||||
if json_mode { eputs(s); } else { out(s); }
|
||||
}
|
||||
|
||||
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted; JSON error under --json)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
|
||||
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store read-only over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n routes: /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
|
||||
|
||||
// True if `name` appears as a token in `args`.
|
||||
has_flag :: (args: []string, name: string) -> bool {
|
||||
@@ -180,12 +182,55 @@ handle_release_rollback :: (p: *Parsed, json_mode: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// `dist server run` — bind 0.0.0.0:<port> and serve the store read-only
|
||||
// (P4.1). Loops forever on success; a bind/listen failure reports through
|
||||
// `report_failure` like every other command.
|
||||
handle_server_run :: (p: *Parsed, json_mode: bool) {
|
||||
store_dir := p.value_of("local-store");
|
||||
port : s64 = 8787;
|
||||
if p.is_set("port") {
|
||||
pq := parse_port(p.value_of("port"));
|
||||
if pq == null {
|
||||
eputs(concat(concat("dist: --port must be 1..65535, got: ", p.value_of("port")), "\n"));
|
||||
exit_usage();
|
||||
}
|
||||
port = pq!;
|
||||
}
|
||||
|
||||
failed := false;
|
||||
srv.run_server(store_dir, port) catch { failed = true; };
|
||||
if failed {
|
||||
fail : jout.CliFailure = .{
|
||||
code = "server.bind",
|
||||
message = concat("could not bind/listen on 0.0.0.0:", int_to_string(port)),
|
||||
};
|
||||
report_failure("server run", fail, json_mode);
|
||||
}
|
||||
}
|
||||
|
||||
// Decimal port in 1..65535; anything else (empty, non-digits, out of
|
||||
// range) is null — a usage error at the call site.
|
||||
parse_port :: (s: string) -> ?s64 {
|
||||
if s.len == 0 or s.len > 5 { return null; }
|
||||
v : s64 = 0;
|
||||
i := 0;
|
||||
while i < s.len {
|
||||
c := s[i];
|
||||
if c < 48 or c > 57 { return null; } // '0'..'9'
|
||||
v = v * 10 + (c - 48);
|
||||
i += 1;
|
||||
}
|
||||
if v < 1 or v > 65535 { return null; }
|
||||
return v;
|
||||
}
|
||||
|
||||
// Route a parsed (group, command) to its handler. `parse` only returns a
|
||||
// (group, command) present in the table, so one arm always matches.
|
||||
dispatch :: (p: *Parsed, json_mode: bool) {
|
||||
if p.group == "ci" and p.command == "publish" { handle_ci_publish(p, json_mode); return; }
|
||||
if p.group == "release" and p.command == "promote" { handle_release_promote(p, json_mode); return; }
|
||||
if p.group == "release" and p.command == "rollback" { handle_release_rollback(p, json_mode); return; }
|
||||
if p.group == "server" and p.command == "run" { handle_server_run(p, json_mode); return; }
|
||||
eputs("dist: internal error: unrouted command\n");
|
||||
exit_usage();
|
||||
}
|
||||
@@ -231,10 +276,15 @@ main :: () -> ! {
|
||||
FlagSpec.{ name = "channel", takes_value = true, required = true },
|
||||
FlagSpec.{ name = "local-store", takes_value = true, required = true },
|
||||
];
|
||||
server_flags : []FlagSpec = .[
|
||||
FlagSpec.{ name = "local-store", takes_value = true, required = true },
|
||||
FlagSpec.{ name = "port", takes_value = true, required = false },
|
||||
];
|
||||
cmds : []Command = .[
|
||||
Command.{ group = "ci", command = "publish", flags = publish_flags },
|
||||
Command.{ group = "release", command = "promote", flags = promote_flags },
|
||||
Command.{ group = "release", command = "rollback", flags = rollback_flags },
|
||||
Command.{ group = "server", command = "run", flags = server_flags },
|
||||
];
|
||||
|
||||
diag : Diag = .{};
|
||||
|
||||
303
src/server/distd.sx
Normal file
303
src/server/distd.sx
Normal file
@@ -0,0 +1,303 @@
|
||||
// =====================================================================
|
||||
// distd.sx — the read-only distribution server over the local store
|
||||
// (subplan 04, Slices 1 + the read half of 3/4), run as `dist server run`.
|
||||
//
|
||||
// Serves the state the CLI publishes — db.json metadata and the
|
||||
// content-addressed objects — over HTTP (src/server/http.sx):
|
||||
//
|
||||
// GET /healthz {"status":"ok"} — no store access
|
||||
// GET /api/apps {"apps":[...]} every app in the store
|
||||
// GET /api/apps/<slug> {"app":..,"releases":[..],"channels":[..]}
|
||||
// GET /download/<sha256> the object's bytes (application/octet-stream,
|
||||
// X-Checksum-SHA256 header)
|
||||
//
|
||||
// Anything else is a JSON error in the CLI's error shape
|
||||
// (`{"status":"error","error":{code,message}}`) with the matching HTTP
|
||||
// status — the API and the CLI report failures identically.
|
||||
//
|
||||
// FRESHNESS: db.json is RELOADED on every /api request, so a `dist ci
|
||||
// publish` / `release promote` between requests is visible immediately —
|
||||
// the store on disk stays the single source of truth (no cache to
|
||||
// invalidate, LAN-scale traffic).
|
||||
//
|
||||
// MUTATION is the CLI's job for now: every route is GET; writes arrive
|
||||
// with token auth (subplan 04 Slice 2, deferred with the rest of the
|
||||
// upload/auth surface).
|
||||
//
|
||||
// RESPONSE BUFFERS are heap slices from the per-request arena, never big
|
||||
// stack arrays: a stack array of 64K+ in one frame crashes the sx LLVM
|
||||
// backend (DAGCombiner segfault). Small fixed buffers (4K) are fine.
|
||||
// =====================================================================
|
||||
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
#import "modules/std/fs.sx";
|
||||
#import "../domain/platform.sx";
|
||||
#import "../domain/app.sx";
|
||||
#import "../domain/release.sx";
|
||||
#import "../domain/artifact.sx";
|
||||
#import "../domain/channel.sx";
|
||||
#import "../domain/audit.sx";
|
||||
#import "../repo/repo.sx";
|
||||
sock :: #import "modules/std/socket.sx";
|
||||
http :: #import "http.sx";
|
||||
db :: #import "../repo/db.sx";
|
||||
jout :: #import "../json_out.sx";
|
||||
|
||||
// Response-body capacity for the /api JSON renders (heap, per-request).
|
||||
RENDER_CAP :: 262144;
|
||||
|
||||
ServeErr :: error {
|
||||
Bind,
|
||||
}
|
||||
|
||||
// Server-side stderr log line (fd 2 via the socket module's write; stdout
|
||||
// stays clean for whoever launched the process).
|
||||
slog :: (s: string) {
|
||||
if s.len > 0 { sock.write(2, s.ptr, xx s.len); }
|
||||
}
|
||||
|
||||
// True iff `s` is exactly 64 lowercase-hex chars (a storage key).
|
||||
is_hex64 :: (s: string) -> bool {
|
||||
if s.len != 64 { return false; }
|
||||
i := 0;
|
||||
while i < s.len {
|
||||
c := s[i];
|
||||
digit := c >= 48 and c <= 57; // '0'..'9'
|
||||
lower := c >= 97 and c <= 102; // 'a'..'f'
|
||||
if !digit and !lower { return false; }
|
||||
i += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
starts_with :: (s: string, prefix: string) -> bool {
|
||||
if prefix.len > s.len { return false; }
|
||||
i := 0;
|
||||
while i < prefix.len {
|
||||
if s[i] != prefix[i] { return false; }
|
||||
i += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The path remainder after `prefix` (caller has checked starts_with).
|
||||
tail_after :: (s: string, prefix: string) -> string {
|
||||
return string.{ ptr = @s[prefix.len], len = s.len - prefix.len };
|
||||
}
|
||||
|
||||
// A `RENDER_CAP` heap slice from the per-request context allocator.
|
||||
render_buf :: () -> string {
|
||||
raw : [*]u8 = xx context.allocator.alloc_bytes(RENDER_CAP);
|
||||
return string.{ ptr = raw, len = RENDER_CAP };
|
||||
}
|
||||
|
||||
// ── responses ─────────────────────────────────────────────────────────
|
||||
|
||||
// JSON error body in the CLI's error shape, sent with the HTTP status.
|
||||
respond_error :: (client: s32, code: s64, fail_code: string, fail_message: string) {
|
||||
f : jout.CliFailure = .{ code = fail_code, message = fail_message };
|
||||
raw : [4096]u8 = ---;
|
||||
werr := false;
|
||||
n := jout.write_error(f, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
|
||||
body := "{\"status\":\"error\"}";
|
||||
if !werr { body = string.{ ptr = @raw[0], len = n }; }
|
||||
http.respond(client, code, "application/json", "", body);
|
||||
}
|
||||
|
||||
// ── /api renders (builders own the `try`, callers catch) ─────────────
|
||||
|
||||
// Reload the persisted model. Null means the store has no readable
|
||||
// db.json — the 503 error response has already been sent.
|
||||
load_or_503 :: (client: s32, store_dir: string) -> ?Repo {
|
||||
if !exists(path_join(store_dir, "db.json")) {
|
||||
respond_error(client, 503, "store.load",
|
||||
concat("no db.json under the store (nothing published yet): ", store_dir));
|
||||
return null;
|
||||
}
|
||||
loaded, le := db.load(store_dir);
|
||||
if le {
|
||||
respond_error(client, 503, "store.load",
|
||||
concat("db.json under the store could not be loaded: ", store_dir));
|
||||
return null;
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
// `{"apps":[...]}` into `dst`, returning the bytes written.
|
||||
render_apps_json :: (repo: *Repo, dst: []u8) -> (s64, !JsonError) {
|
||||
alloc := context.allocator;
|
||||
root : Object = .{};
|
||||
arr : Array = .{};
|
||||
i := 0;
|
||||
while i < repo.apps.len {
|
||||
arr.add(db.app_to_json(repo.apps.items[i], alloc), alloc);
|
||||
i += 1;
|
||||
}
|
||||
root.put("apps", .array(arr), alloc);
|
||||
rootv : Value = .object(root);
|
||||
n := try write_to_buffer(rootv, dst);
|
||||
return n;
|
||||
}
|
||||
|
||||
// `{"app":..,"releases":[..],"channels":[..]}` for `app` into `dst`.
|
||||
render_app_detail_json :: (repo: *Repo, app: App, dst: []u8) -> (s64, !JsonError) {
|
||||
alloc := context.allocator;
|
||||
root : Object = .{};
|
||||
root.put("app", db.app_to_json(app, alloc), alloc);
|
||||
|
||||
rels : Array = .{};
|
||||
i := 0;
|
||||
while i < repo.releases.len {
|
||||
r := repo.releases.items[i];
|
||||
if r.app_id == app.id { rels.add(db.release_to_json(r, alloc), alloc); }
|
||||
i += 1;
|
||||
}
|
||||
root.put("releases", .array(rels), alloc);
|
||||
|
||||
chans : Array = .{};
|
||||
i = 0;
|
||||
while i < repo.channels.len {
|
||||
c := repo.channels.items[i];
|
||||
if c.app_id == app.id { chans.add(db.channel_to_json(c, alloc), alloc); }
|
||||
i += 1;
|
||||
}
|
||||
root.put("channels", .array(chans), alloc);
|
||||
|
||||
rootv : Value = .object(root);
|
||||
n := try write_to_buffer(rootv, dst);
|
||||
return n;
|
||||
}
|
||||
|
||||
// Send `n` rendered bytes of `buf` as 200 JSON — or the overflow error
|
||||
// when the render didn't fit RENDER_CAP.
|
||||
respond_render :: (client: s32, buf: string, n: s64, overflowed: bool) {
|
||||
if overflowed {
|
||||
respond_error(client, 500, "http.response_overflow",
|
||||
"response exceeded the server's render buffer");
|
||||
return;
|
||||
}
|
||||
http.respond(client, 200, "application/json", "", string.{ ptr = buf.ptr, len = n });
|
||||
}
|
||||
|
||||
// ── routes ────────────────────────────────────────────────────────────
|
||||
|
||||
handle_apps_index :: (client: s32, store_dir: string) {
|
||||
rq := load_or_503(client, store_dir);
|
||||
if rq == null { return; }
|
||||
repo := rq!;
|
||||
|
||||
buf := render_buf();
|
||||
werr := false;
|
||||
n := render_apps_json(@repo, buf) catch { werr = true; 0 };
|
||||
respond_render(client, buf, n, werr);
|
||||
}
|
||||
|
||||
handle_app_detail :: (client: s32, store_dir: string, slug: string) {
|
||||
rq := load_or_503(client, store_dir);
|
||||
if rq == null { return; }
|
||||
repo := rq!;
|
||||
|
||||
aq := repo.find_app_by_slug(slug);
|
||||
if aq == null {
|
||||
respond_error(client, 404, "api.unknown_app",
|
||||
concat("no app with that slug in the store: ", slug));
|
||||
return;
|
||||
}
|
||||
|
||||
buf := render_buf();
|
||||
werr := false;
|
||||
n := render_app_detail_json(@repo, aq!, buf) catch { werr = true; 0 };
|
||||
respond_render(client, buf, n, werr);
|
||||
}
|
||||
|
||||
handle_download :: (client: s32, store_dir: string, sha: string) {
|
||||
if !is_hex64(sha) {
|
||||
respond_error(client, 404, "download.bad_key",
|
||||
"download key must be a 64-char lowercase-hex sha256");
|
||||
return;
|
||||
}
|
||||
opath := path_join(store_dir, concat("objects/", sha));
|
||||
bq := read_file(opath);
|
||||
if bq == null {
|
||||
respond_error(client, 404, "download.unknown_object",
|
||||
concat("no object with that digest in the store: ", sha));
|
||||
return;
|
||||
}
|
||||
extra := concat("X-Checksum-SHA256: ", concat(sha, "\r\n"));
|
||||
http.respond(client, 200, "application/octet-stream", extra, bq!);
|
||||
}
|
||||
|
||||
// Route one parsed request. GET only; the path decides the handler.
|
||||
route :: (client: s32, store_dir: string, method: string, path: string) -> s64 {
|
||||
if method != "GET" {
|
||||
respond_error(client, 405, "http.method_not_allowed",
|
||||
"every distd route is GET for now (writes go through the dist CLI)");
|
||||
return 405;
|
||||
}
|
||||
if path == "/healthz" {
|
||||
http.respond(client, 200, "application/json", "", "{\"status\":\"ok\"}");
|
||||
return 200;
|
||||
}
|
||||
if path == "/api/apps" {
|
||||
handle_apps_index(client, store_dir);
|
||||
return 200;
|
||||
}
|
||||
if starts_with(path, "/api/apps/") {
|
||||
handle_app_detail(client, store_dir, tail_after(path, "/api/apps/"));
|
||||
return 200;
|
||||
}
|
||||
if starts_with(path, "/download/") {
|
||||
handle_download(client, store_dir, tail_after(path, "/download/"));
|
||||
return 200;
|
||||
}
|
||||
respond_error(client, 404, "http.not_found",
|
||||
concat("no route for ", path));
|
||||
return 404;
|
||||
}
|
||||
|
||||
// ── server loop ───────────────────────────────────────────────────────
|
||||
|
||||
// Read one request off `client`, route it, log the result line. All
|
||||
// allocations land in the pushed per-request context allocator.
|
||||
serve_one :: (client: s32, store_dir: string) {
|
||||
buf : [8192]u8 = ---;
|
||||
n := sock.read(client, @buf[0], 8192);
|
||||
if n <= 0 { return; }
|
||||
|
||||
raw := string.{ ptr = @buf[0], len = xx n };
|
||||
req : http.Request = .{};
|
||||
if !http.parse_request(raw, @req) {
|
||||
respond_error(client, 400, "http.bad_request",
|
||||
"request line did not parse as HTTP");
|
||||
return;
|
||||
}
|
||||
code := route(client, store_dir, req.method, req.path);
|
||||
line := concat("distd: ", concat(req.method, concat(" ", concat(req.path, concat(" -> ", concat(int_to_string(code), "\n"))))));
|
||||
slog(line);
|
||||
}
|
||||
|
||||
// Bind 0.0.0.0:<port> and serve forever (sequential, one connection at a
|
||||
// time — the deployment story is LAN/NAS-scale). Returns only when the
|
||||
// socket can't be opened. Per-request allocations live in an arena that
|
||||
// dies with the request.
|
||||
run_server :: (store_dir: string, port: s64) -> !ServeErr {
|
||||
fd, fe := http.listen_on(port);
|
||||
if fe { raise error.Bind; }
|
||||
|
||||
slog(concat("distd: serving store ", concat(store_dir, concat(" on http://0.0.0.0:", concat(int_to_string(port), "\n")))));
|
||||
|
||||
while true {
|
||||
client := sock.accept(fd, null, null);
|
||||
if client < 0 { continue; }
|
||||
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 65536);
|
||||
push Context.{ allocator = xx arena } {
|
||||
serve_one(client, store_dir);
|
||||
}
|
||||
arena.deinit();
|
||||
sock.close(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
88
src/server/http.sx
Normal file
88
src/server/http.sx
Normal file
@@ -0,0 +1,88 @@
|
||||
// =====================================================================
|
||||
// http.sx — minimal HTTP/1.1 over `std.socket` (subplan 04, Slice 1).
|
||||
//
|
||||
// The temporary in-repo boundary for the missing `std.http`, written so it
|
||||
// can be lifted into the sx stdlib later. Deliberately minimal — exactly
|
||||
// what a read-only JSON API + artifact download server needs:
|
||||
//
|
||||
// * `listen_on(port)` — a listening TCP socket on 0.0.0.0:<port>
|
||||
// (INADDR_ANY, so the server is reachable from the LAN);
|
||||
// * `parse_request` — method + path views off the request line;
|
||||
// * `respond` — one full response: status line, Content-Type/-Length,
|
||||
// `Connection: close`, optional extra header lines, body.
|
||||
//
|
||||
// NOT handled (v0, documented): request headers and bodies (the routes are
|
||||
// GET-only and need neither), keep-alive (every response closes), chunked
|
||||
// transfer, TLS (the deployment plan terminates TLS at a reverse proxy).
|
||||
// A request is read in ONE `read` — request lines of interest fit the
|
||||
// first segment; anything that doesn't parse is a 400.
|
||||
// =====================================================================
|
||||
|
||||
#import "modules/std.sx";
|
||||
sock :: #import "modules/std/socket.sx";
|
||||
|
||||
HttpError :: error {
|
||||
Socket,
|
||||
Bind,
|
||||
Listen,
|
||||
}
|
||||
|
||||
// Method + path of one request, as VIEWS into the caller's read buffer.
|
||||
Request :: struct {
|
||||
method: string = "";
|
||||
path: string = "";
|
||||
}
|
||||
|
||||
// Open a listening socket on 0.0.0.0:<port>. SO_REUSEADDR so a restarted
|
||||
// server can re-bind without waiting out TIME_WAIT.
|
||||
listen_on :: (port: s64) -> (s32, !HttpError) {
|
||||
fd := sock.socket(sock.AF_INET, sock.SOCK_STREAM, 0);
|
||||
if fd < 0 { raise error.Socket; }
|
||||
opt : s32 = 1;
|
||||
sock.setsockopt(fd, sock.SOL_SOCKET, sock.SO_REUSEADDR, @opt, 4);
|
||||
addr : sock.SockAddr = .{ sin_len = 16, sin_family = 2, sin_port = sock.htons(port) };
|
||||
if sock.bind(fd, @addr, 16) < 0 { sock.close(fd); raise error.Bind; }
|
||||
if sock.listen(fd, 16) < 0 { sock.close(fd); raise error.Listen; }
|
||||
return fd;
|
||||
}
|
||||
|
||||
// Parse the request line `METHOD SP PATH SP HTTP/x.y` off the raw bytes.
|
||||
// False when the bytes don't look like an HTTP request line.
|
||||
parse_request :: (raw: string, req: *Request) -> bool {
|
||||
i := 0;
|
||||
while i < raw.len and raw[i] != 32 { i += 1; } // 32 = ' '
|
||||
if i == 0 or i >= raw.len { return false; }
|
||||
req.method = string.{ ptr = raw.ptr, len = i };
|
||||
|
||||
j := i + 1;
|
||||
k := j;
|
||||
while k < raw.len and raw[k] != 32 and raw[k] != 13 { k += 1; } // 13 = '\r'
|
||||
if k == j or k >= raw.len { return false; }
|
||||
if raw[k] != 32 { return false; } // no HTTP version after path
|
||||
req.path = string.{ ptr = @raw[j], len = k - j };
|
||||
return true;
|
||||
}
|
||||
|
||||
status_text :: (code: s64) -> string {
|
||||
if code == 200 { return "OK"; }
|
||||
if code == 400 { return "Bad Request"; }
|
||||
if code == 404 { return "Not Found"; }
|
||||
if code == 405 { return "Method Not Allowed"; }
|
||||
if code == 503 { return "Service Unavailable"; }
|
||||
return "Internal Server Error";
|
||||
}
|
||||
|
||||
// Write one complete response and leave the connection for the caller to
|
||||
// close. `extra` is zero or more pre-formatted header lines, each ending
|
||||
// in `\r\n` ("" for none).
|
||||
respond :: (client: s32, code: s64, content_type: string, extra: string, body: string) {
|
||||
h := concat("HTTP/1.1 ", concat(int_to_string(code), concat(" ", status_text(code))));
|
||||
h = concat(h, "\r\n");
|
||||
h = concat(h, concat("Content-Type: ", concat(content_type, "\r\n")));
|
||||
h = concat(h, concat("Content-Length: ", concat(int_to_string(body.len), "\r\n")));
|
||||
h = concat(h, "Connection: close\r\n");
|
||||
h = concat(h, extra);
|
||||
h = concat(h, "\r\n");
|
||||
sock.write(client, h.ptr, xx h.len);
|
||||
if body.len > 0 { sock.write(client, body.ptr, xx body.len); }
|
||||
}
|
||||
166
tests/server_http.sx
Normal file
166
tests/server_http.sx
Normal file
@@ -0,0 +1,166 @@
|
||||
// Pinned acceptance for P4.1 — `dist server run` serves the local store
|
||||
// read-only over HTTP.
|
||||
//
|
||||
// Publishes the fixture manifest into a fresh store, starts the BUILT
|
||||
// `build/dist server run` on a test port in the background, waits for
|
||||
// /healthz, and asserts every route against curl:
|
||||
//
|
||||
// * /healthz → {"status":"ok"}
|
||||
// * /api/apps → the published app is listed
|
||||
// * /api/apps/<slug> → app + its releases + channels
|
||||
// * /api/apps/<unknown> → JSON error api.unknown_app
|
||||
// * /download/<sha256> → bytes identical to the source fixture
|
||||
// * /download/<unknown sha> → JSON error download.unknown_object
|
||||
// * any other path → JSON error http.not_found
|
||||
//
|
||||
// FRESHNESS is asserted by publishing a SECOND version while the server
|
||||
// is running: the next /api/apps/<slug> must list both releases (db.json
|
||||
// is reloaded per request — no stale cache).
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
|
||||
STORE :: ".sx-tmp/server_http";
|
||||
MDIR :: ".sx-tmp/server_http_m";
|
||||
PORT :: "18792";
|
||||
BASE :: "http://127.0.0.1:18792";
|
||||
|
||||
MANIFEST_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
|
||||
MANIFEST_B :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
|
||||
|
||||
get :: (o: Object, key: string) -> Value {
|
||||
i := 0;
|
||||
while i < o.len {
|
||||
if o.items[i].key == key { return o.items[i].val; }
|
||||
i += 1;
|
||||
}
|
||||
process.assert(false, concat("missing json key: ", key));
|
||||
dummy : Value = .null_;
|
||||
return dummy;
|
||||
}
|
||||
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
|
||||
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
|
||||
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
|
||||
|
||||
write_file :: (path: string, body: string) {
|
||||
cmd := concat(concat(concat("printf '%s' '", body), "' > "), path);
|
||||
process.run(cmd);
|
||||
}
|
||||
|
||||
// GET `path` and return the body (curl; 2s timeout so a dead server fails
|
||||
// the test instead of hanging it).
|
||||
fetch :: (path: string) -> string {
|
||||
r := process.run(concat(concat("curl -s -m 2 ", BASE), path));
|
||||
process.assert(r != null, concat("curl spawn failed: ", path));
|
||||
return r!.stdout;
|
||||
}
|
||||
|
||||
// GET `path` and return the HTTP status code as text.
|
||||
fetch_code :: (path: string) -> string {
|
||||
r := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", BASE), path));
|
||||
process.assert(r != null, concat("curl spawn failed: ", path));
|
||||
return r!.stdout;
|
||||
}
|
||||
|
||||
// Parse a fetched JSON body (hard assert on parse failure).
|
||||
parse_body :: (body: string, what: string, scratch: Allocator) -> Object {
|
||||
v, e := parse(body, scratch);
|
||||
if e {
|
||||
process.assert(false, concat("response must be valid JSON: ", what));
|
||||
dummy : Object = .{};
|
||||
return dummy;
|
||||
}
|
||||
return v.object;
|
||||
}
|
||||
|
||||
publish_cmd :: (mpath: string) -> string {
|
||||
c := concat("build/dist ci publish --manifest ", mpath);
|
||||
c = concat(c, concat(" --local-store ", STORE));
|
||||
return concat(c, " --json 2>/dev/null >/dev/null");
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 1 << 20);
|
||||
defer arena.deinit();
|
||||
|
||||
// Fresh state; clear any stale server from a crashed prior run.
|
||||
process.run("pkill -f 'dist server run --local-store .sx-tmp/server_http' 2>/dev/null");
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
process.run(concat("rm -rf ", MDIR));
|
||||
process.run(concat("mkdir -p ", MDIR));
|
||||
write_file(path_join(MDIR, "a.json"), MANIFEST_A);
|
||||
write_file(path_join(MDIR, "b.json"), MANIFEST_B);
|
||||
|
||||
ra := process.run(publish_cmd(path_join(MDIR, "a.json")));
|
||||
process.assert(ra != null and ra!.exit_code == 0, "publish A must exit 0");
|
||||
|
||||
// ── start the server, hold its pid, poll /healthz until ready ─────
|
||||
sp := process.run(concat(concat(concat("sh -c 'build/dist server run --local-store ", STORE), concat(concat(" --port ", PORT), " >/dev/null 2>&1 & echo $!'")), ""));
|
||||
process.assert(sp != null, "server spawn failed");
|
||||
pid := sp!.stdout;
|
||||
|
||||
ready := false;
|
||||
tries := 0;
|
||||
while tries < 50 {
|
||||
c := fetch_code("/healthz");
|
||||
if c == "200" { ready = true; break; }
|
||||
process.run("sleep 0.2");
|
||||
tries += 1;
|
||||
}
|
||||
process.assert(ready, "server must answer /healthz within 10s");
|
||||
print(" server up\n");
|
||||
|
||||
// ── routes ────────────────────────────────────────────────────────
|
||||
hz := parse_body(fetch("/healthz"), "/healthz", xx arena);
|
||||
process.assert(get_str(hz, "status") == "ok", "/healthz status ok");
|
||||
|
||||
apps := parse_body(fetch("/api/apps"), "/api/apps", xx arena);
|
||||
apps_arr := get_arr(apps, "apps");
|
||||
process.assert(apps_arr.len == 1, "/api/apps lists one app");
|
||||
process.assert(get_str(apps_arr.items[0].object, "slug") == "acme-app", "/api/apps lists acme-app");
|
||||
|
||||
det := parse_body(fetch("/api/apps/acme-app"), "/api/apps/acme-app", xx arena);
|
||||
process.assert(get_str(get_obj(det, "app"), "slug") == "acme-app", "detail names the app");
|
||||
process.assert(get_arr(det, "releases").len == 1, "detail lists one release");
|
||||
process.assert(get_arr(det, "channels").len == 1, "detail lists one channel");
|
||||
|
||||
process.assert(fetch_code("/api/apps/nope") == "404", "unknown slug is 404");
|
||||
nf := parse_body(fetch("/api/apps/nope"), "unknown slug body", xx arena);
|
||||
process.assert(get_str(get_obj(nf, "error"), "code") == "api.unknown_app", "unknown slug names api.unknown_app");
|
||||
|
||||
process.assert(fetch_code("/bogus") == "404", "unknown route is 404");
|
||||
nr := parse_body(fetch("/bogus"), "unknown route body", xx arena);
|
||||
process.assert(get_str(get_obj(nr, "error"), "code") == "http.not_found", "unknown route names http.not_found");
|
||||
print(" api routes ok\n");
|
||||
|
||||
// ── download: bytes identical to the source fixture ───────────────
|
||||
db_bytes := fs.read_file(path_join(STORE, "db.json"));
|
||||
process.assert(db_bytes != null, "db.json must exist");
|
||||
dbo := parse_body(db_bytes!, "db.json", xx arena);
|
||||
sha := get_str(get_arr(dbo, "artifacts").items[0].object, "sha256");
|
||||
|
||||
dl := process.run(concat(concat(concat(concat("curl -s -m 2 -o .sx-tmp/server_http_dl.bin ", BASE), "/download/"), sha), " && cmp -s .sx-tmp/server_http_dl.bin examples/fixtures/acme-1.2.3-android.apk && echo SAME"));
|
||||
process.assert(dl != null, "download curl spawn failed");
|
||||
process.assert(dl!.stdout == "SAME\n", "downloaded bytes must equal the source fixture");
|
||||
|
||||
bad := parse_body(fetch("/download/0000000000000000000000000000000000000000000000000000000000000000"), "unknown object body", xx arena);
|
||||
process.assert(get_str(get_obj(bad, "error"), "code") == "download.unknown_object", "unknown digest names download.unknown_object");
|
||||
print(" download ok\n");
|
||||
|
||||
// ── freshness: publish B while the server runs ────────────────────
|
||||
rb := process.run(publish_cmd(path_join(MDIR, "b.json")));
|
||||
process.assert(rb != null and rb!.exit_code == 0, "publish B must exit 0");
|
||||
det2 := parse_body(fetch("/api/apps/acme-app"), "detail after B", xx arena);
|
||||
process.assert(get_arr(det2, "releases").len == 2, "server reflects the new release without restart");
|
||||
print(" per-request reload ok\n");
|
||||
|
||||
// ── teardown ──────────────────────────────────────────────────────
|
||||
process.run(concat("kill ", pid));
|
||||
process.run("rm -f .sx-tmp/server_http_dl.bin");
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
process.run(concat("rm -rf ", MDIR));
|
||||
print("server_http: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user