P4.5: remote dist ci publish --server --token
The PLAN.md CI contract in remote mode. client/http_client.sx is the temporary in-repo HTTP client shim over std.socket (connect(2) via libc FFI), targeting http://<ipv4-or-localhost>:<port> — DNS and TLS are loud v0 boundaries (https:// is refused; the deployment story terminates TLS at a reverse proxy). publish/remote.sx drives the same manifest front half as local publish, uploads each artifact through POST /api/upload (verifying a manifest-declared sha256 against the server-computed digest), then publishes via POST /api/apps/<slug>/releases. The 200 response is parsed back into a PublishOutcome so --json/human rendering reuse the local paths; non-2xx responses pass the server's JSON error code through report_failure. ci publish takes exactly one of --local-store or --server (+--token); anything else is a usage error. make test 18/18 (new: remote_publish.sx pinned acceptance against a live distd).
This commit is contained in:
160
tests/remote_publish.sx
Normal file
160
tests/remote_publish.sx
Normal file
@@ -0,0 +1,160 @@
|
||||
// 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.
|
||||
// * 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";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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");
|
||||
dbo := parse_body(fs.read_file(path_join(STORE, "db.json"))!, "db.json", xx arena);
|
||||
rels := get_arr(dbo, "releases");
|
||||
process.assert(rels.len == 1, "server db records the release");
|
||||
process.assert(get_str(rels.items[0].object, "created_by") == "token:ci-remote",
|
||||
"release created_by carries the token actor");
|
||||
chans := get_arr(dbo, "channels");
|
||||
process.assert(chans.len == 1, "server db records the channel");
|
||||
process.assert(get_str(chans.items[0].object, "current_release_id") == "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;
|
||||
}
|
||||
Reference in New Issue
Block a user