The amalgamation and the bindings now ship with sx itself (sx library/vendors/sqlite/ — bindings + c/ amalgamation); every import flips from ../src/db/sqlite.sx to vendors/sqlite/sqlite.sx, resolved through the compiler's stdlib search paths. vendor/ and src/db/ leave this repo entirely. make test 22/22 — the object cache keys on content, not path, so the relocated source still hits the existing cache entries.
196 lines
9.6 KiB
Plaintext
196 lines
9.6 KiB
Plaintext
// Pinned acceptance for P4.5 — `dist ci publish --server --token`, the
|
|
// PLAN.md CI contract in remote mode.
|
|
//
|
|
// Mints a token in a fresh store, starts the BUILT `build/dist server
|
|
// run` over it, then drives the BUILT CLI against the live server:
|
|
//
|
|
// * happy path: exit 0; stdout is the publish JSON (status published,
|
|
// release id, per-artifact sha256 equal to an independent digest of
|
|
// the fixture); the SERVER's store gained objects/<sha>, the release,
|
|
// the channel pointer, and `token:<name>` audit actors (store state
|
|
// queried from `<store>/dist.db` via the SQLite bindings).
|
|
// * duplicate version: exit 1, the server's transaction.integrity code
|
|
// passed through verbatim.
|
|
// * wrong secret: exit 1, auth.unknown_token.
|
|
// * https:// target: exit 1, server.tls_unsupported (TLS terminates at
|
|
// a reverse proxy by design).
|
|
// * malformed --server: exit 1, server.bad_url.
|
|
// * unreachable server: exit 1, server.unreachable.
|
|
// * --local-store together with --server: usage error (exit 64).
|
|
#import "modules/std.sx";
|
|
#import "modules/std/json.sx";
|
|
process :: #import "modules/std/process.sx";
|
|
fs :: #import "modules/std/fs.sx";
|
|
hash :: #import "modules/std/hash.sx";
|
|
sq :: #import "vendors/sqlite/sqlite.sx";
|
|
|
|
STORE :: ".sx-tmp/remote_publish";
|
|
MDIR :: ".sx-tmp/remote_publish_m";
|
|
PORT :: "18794";
|
|
SERVER :: "http://127.0.0.1:18794";
|
|
FIXTURE :: "examples/fixtures/acme-1.2.3-android.apk";
|
|
|
|
MANIFEST :: "{\"app\":\"acme-app\",\"version\":\"2.0.0\",\"channel\":\"beta\",\"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; }
|
|
|
|
parse_body :: (body: string, what: string, scratch: Allocator) -> Object {
|
|
v, e := parse(body, scratch);
|
|
if e {
|
|
process.assert(false, concat("must be valid JSON: ", what));
|
|
dummy : Object = .{};
|
|
return dummy;
|
|
}
|
|
return v.object;
|
|
}
|
|
|
|
// One-row scalar queries over `<STORE>/dist.db` ("" = unbound binding).
|
|
db_open_ro :: () -> sq.Sqlite {
|
|
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
|
process.assert(!oe, "dist.db must open as a SQLite database");
|
|
c.busy_timeout(2000);
|
|
return c;
|
|
}
|
|
|
|
q_text :: (sql: string, p1: string) -> string {
|
|
c := db_open_ro();
|
|
st, pe := c.prepare(sql);
|
|
process.assert(!pe, concat("prepare must succeed: ", sql));
|
|
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
|
rc, se := st.step();
|
|
process.assert(!se, concat("step must succeed: ", sql));
|
|
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
|
out := st.column_text(0);
|
|
st.finalize();
|
|
c.close();
|
|
return out;
|
|
}
|
|
|
|
q_int :: (sql: string, p1: string) -> i64 {
|
|
c := db_open_ro();
|
|
st, pe := c.prepare(sql);
|
|
process.assert(!pe, concat("prepare must succeed: ", sql));
|
|
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
|
rc, se := st.step();
|
|
process.assert(!se, concat("step must succeed: ", sql));
|
|
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
|
out := st.column_int64(0);
|
|
st.finalize();
|
|
c.close();
|
|
return out;
|
|
}
|
|
|
|
// Run a remote publish with `server` and `token`; returns the run result.
|
|
remote_publish :: (server: string, token: string) -> ?process.ProcessResult {
|
|
cmd := concat("build/dist ci publish --manifest ", path_join(MDIR, "m.json"));
|
|
cmd = concat(cmd, concat(" --server ", server));
|
|
cmd = concat(cmd, concat(" --token ", token));
|
|
return process.run(concat(cmd, " --json 2>/dev/null"));
|
|
}
|
|
|
|
// Assert `r` failed (exit 1) with the given JSON error code.
|
|
assert_fails_with :: (r: ?process.ProcessResult, code: string, what: string, scratch: Allocator) {
|
|
process.assert(r != null, concat("spawn failed: ", what));
|
|
process.assert(r!.exit_code == 1, concat("must exit 1: ", what));
|
|
o := parse_body(r!.stdout, what, scratch);
|
|
process.assert(get_str(o, "status") == "error", concat("json status error: ", what));
|
|
process.assert(get_str(get_obj(o, "error"), "code") == code,
|
|
concat(concat("error code must be ", code), concat(": ", what)));
|
|
}
|
|
|
|
main :: () -> i32 {
|
|
gpa := GPA.init();
|
|
arena := Arena.init(xx gpa, 1 << 20);
|
|
defer arena.deinit();
|
|
|
|
process.run("pkill -f 'dist server run --local-store .sx-tmp/remote_publish' 2>/dev/null");
|
|
process.run(concat("rm -rf ", STORE));
|
|
process.run(concat("rm -rf ", MDIR));
|
|
process.run(concat("mkdir -p ", MDIR));
|
|
process.run(concat(concat(concat("printf '%s' '", MANIFEST), "' > "), path_join(MDIR, "m.json")));
|
|
|
|
// ── token + server ─────────────────────────────────────────────────
|
|
tc := process.run("build/dist token create --name ci-remote --local-store .sx-tmp/remote_publish --json 2>/dev/null");
|
|
process.assert(tc != null and tc!.exit_code == 0, "token create must exit 0");
|
|
secret := get_str(get_obj(parse_body(tc!.stdout, "token create", xx arena), "token"), "secret");
|
|
|
|
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 := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", SERVER), "/healthz"));
|
|
if c != null {
|
|
if c!.stdout == "200" { ready = true; break; }
|
|
}
|
|
process.run("sleep 0.2");
|
|
tries += 1;
|
|
}
|
|
process.assert(ready, "server must answer /healthz within 10s");
|
|
print(" server up\n");
|
|
|
|
// ── happy path ─────────────────────────────────────────────────────
|
|
fixture_bytes := fs.read_file(FIXTURE);
|
|
process.assert(fixture_bytes != null, "fixture must be readable");
|
|
d := hash.sha256_hex(fixture_bytes!);
|
|
expect_sha := string.{ ptr = @d[0], len = 64 };
|
|
|
|
hp := remote_publish(SERVER, secret);
|
|
process.assert(hp != null, "remote publish spawn failed");
|
|
process.assert(hp!.exit_code == 0, "remote publish must exit 0");
|
|
o := parse_body(hp!.stdout, "remote publish stdout", xx arena);
|
|
process.assert(get_str(o, "status") == "published", "json status published");
|
|
process.assert(get_str(get_obj(o, "release"), "id") == "rel-acme-app-2.0.0", "json names the release");
|
|
arts := get_arr(o, "artifacts");
|
|
process.assert(arts.len == 1, "json lists one artifact");
|
|
process.assert(get_str(arts.items[0].object, "sha256") == expect_sha,
|
|
"artifact sha256 equals an independent digest of the fixture");
|
|
|
|
process.assert(fs.exists(path_join(STORE, concat("objects/", expect_sha))),
|
|
"the SERVER's store holds the uploaded object");
|
|
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "server db records the release");
|
|
process.assert(q_text("SELECT created_by FROM releases", "") == "token:ci-remote",
|
|
"release created_by carries the token actor");
|
|
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "server db records the channel");
|
|
process.assert(q_text("SELECT current_release_id FROM channels", "") == "rel-acme-app-2.0.0",
|
|
"beta points at the published release");
|
|
print(" remote publish: CI contract round trip ok\n");
|
|
|
|
// ── failure paths ──────────────────────────────────────────────────
|
|
assert_fails_with(remote_publish(SERVER, secret), "transaction.integrity",
|
|
"duplicate version", xx arena);
|
|
assert_fails_with(remote_publish(SERVER, "dist_wrong"), "auth.unknown_token",
|
|
"wrong secret", xx arena);
|
|
assert_fails_with(remote_publish("https://127.0.0.1:18794", secret), "server.tls_unsupported",
|
|
"https target", xx arena);
|
|
assert_fails_with(remote_publish("http://127.0.0.1", secret), "server.bad_url",
|
|
"url without port", xx arena);
|
|
assert_fails_with(remote_publish("http://127.0.0.1:18790", secret), "server.unreachable",
|
|
"unreachable server", xx arena);
|
|
|
|
both := process.run(concat(concat("build/dist ci publish --manifest ", path_join(MDIR, "m.json")), concat(concat(" --server ", SERVER), " --token x --local-store .sx-tmp/remote_publish --json 2>/dev/null")));
|
|
process.assert(both != null and both!.exit_code == 64, "--server with --local-store is a usage error");
|
|
print(" failure paths: server codes passed through, usage gated\n");
|
|
|
|
// ── teardown ───────────────────────────────────────────────────────
|
|
process.run(concat("kill ", pid));
|
|
process.run(concat("rm -rf ", STORE));
|
|
process.run(concat("rm -rf ", MDIR));
|
|
print("remote_publish: ALL CASES PASS\n");
|
|
return 0;
|
|
}
|