///|
pub(all) struct LibraryBuildRequest {
project : @engine.LibraryProject
snapshot : String
source_identity : String
target : String
}
///|
pub(all) struct LibraryBuildLease {
id : String
build : (LibraryBuildRequest) -> Json raise
dispose : () -> Unit raise
}
///|
/// A library build capability is separate from embedded package compilation.
pub(all) struct LibraryBuildProvider {
contracts : Array[String]
acquire : () -> LibraryBuildLease raise
}
///|
pub(all) struct ValidatedProject {
project_id : String
root : String
target : String
source_identity : String
receipt : String
evidence : Json
}
///|
fn build_field(value : Json, name : String) -> Json raise {
match value {
Object(fields) => fields.get(name).unwrap_or(Json::null())
_ => raise @config.Invalid("build.invalid_evidence: Expected object")
}
}
///|
fn build_text(value : Json, name : String) -> String raise {
match build_field(value, name) {
String(s) => s
_ => raise @config.Invalid("build.invalid_evidence: Missing " + name)
}
}
///|
fn digest_shape(value : String) -> Bool {
value.length() == 64 &&
value.to_array().all(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))
}
///|
fn project_snapshot(
project : @engine.LibraryProject,
artifacts : Array[@engine.ContentArtifact],
) -> String raise {
let paths = project.members.copy()
paths.sort()
Json::array(
paths.map(path => {
let matches = artifacts.filter(a => a.path == path)
if matches.length() != 1 {
raise @config.Invalid("build.invalid_membership: " + path)
}
let relative = path[project.root.length() + 1:].to_owned()
Json::array([
Json::string(relative),
match matches[0].content {
Text(s) => Json::array([Json::string("text"), Json::string(s)])
Binary(b) =>
Json::array([
Json::string("binary"),
Json::array(
b.to_array().map(v => Json::number(v.to_int().to_double())),
),
])
},
])
}),
).stringify()
}
///|
fn receipt_ion(value : Json) -> @ion.IonValue raise {
// Evidence is deliberately restricted to strings, booleans, objects and arrays.
match value {
String(s) => @ion.Ion(@ion.IonValueKind::String(s))
True => @ion.Ion(@ion.IonValueKind::Bool(true))
False => @ion.Ion(@ion.IonValueKind::Bool(false))
Array(xs) => @ion.Ion(@ion.IonValueKind::List(xs.map(receipt_ion)))
Object(fields) => {
let names = fields.keys().collect()
names.sort()
@ion.Ion(
@ion.IonValueKind::Struct(
names.map(n => @ion.field(n, receipt_ion(fields[n]))),
),
)
}
_ =>
raise @config.Invalid(
"build.invalid_evidence: Receipt contains unsupported data",
)
}
}
///|
fn validate_evidence(
request : LibraryBuildRequest,
lease : String,
evidence : Json,
compiler_version~ : String = @toolchain.compiler_version,
) -> Unit raise {
if !(build_field(evidence, "successful") is True) ||
!(build_field(evidence, "frozen") is True) ||
build_text(evidence, "leaseId") != lease ||
build_text(evidence, "projectId") != request.project.id ||
build_text(evidence, "contract") != request.project.build_contract ||
build_text(evidence, "sourceIdentity") != request.source_identity ||
build_text(evidence, "target") != request.target {
raise @config.Invalid(
"build.evidence_mismatch: Build does not attest to the requested frozen project",
)
}
if build_text(evidence, "compilerVersion") != compiler_version ||
!digest_shape(build_text(evidence, "compilerIdentity")) ||
!digest_shape(build_text(evidence, "coreIdentity")) ||
!digest_shape(build_text(evidence, "buildIdentity")) {
raise @config.Invalid(
"build.toolchain_mismatch: Pinned compiler and identified matching core are required",
)
}
let deps = match build_field(evidence, "dependencies") {
Array(items) => items
_ => raise @config.Invalid("build.unresolved_dependency")
}
if deps.length() != request.project.dependencies.length() {
raise @config.Invalid(
"build.unresolved_dependency: Dependency evidence count differs",
)
}
let seen = Map([])
for dep in deps {
let name = build_text(dep, "moduleName")
if seen.contains(name) || !digest_shape(build_text(dep, "sourceIdentity")) {
raise @config.Invalid(
"build.unresolved_dependency: Invalid source identity",
)
}
seen[name] = true
if !request.project.dependencies.any(required => {
required.module_name == name &&
required.version == build_text(dep, "version") &&
required.semantic_pin == build_text(dep, "semanticPin") &&
required.api_profile == build_text(dep, "apiProfile")
}) {
raise @config.Invalid("build.unresolved_dependency: " + name)
}
}
}
///|
fn Host::validate_libraries(
self : Host,
report : @engine.ContentReport,
) -> Array[ValidatedProject] raise {
let validated = []
let receipts = []
let leases = Map([])
for project in report.projects {
guard project.policy is @engine.Required(target) else { continue }
if (self.cancelled)() {
raise @config.Invalid("Cancelled")
}
let provider = match self.library_build {
Some(p) => p
None =>
raise @config.Invalid(
"build.provider_required: Configure a library build provider",
)
}
if !provider.contracts.contains(project.build_contract) {
raise @config.Invalid(
"build.unsupported_contract: Provider cannot build " +
project.build_contract,
)
}
let receipt = project.root + "/build-receipt.ionb"
if report.artifacts.any(a => a.path == receipt) {
raise @config.Invalid("build.receipt_collision")
}
let snapshot = project_snapshot(project, report.artifacts)
let source_identity = @identity.sha256(@utf8.encode(snapshot))
let request : LibraryBuildRequest = {
project: {
..project,
members: project.members.copy(),
dependencies: project.dependencies.copy(),
},
snapshot,
source_identity,
target,
}
let lease = (provider.acquire)()
let mut failure : String? = None
let mut evidence : Json? = None
try {
if lease.id == "" || leases.contains(lease.id) {
raise @config.Invalid("build.invalid_lease")
}
leases[lease.id] = true
(self.event)(Phase("build " + project.root))
let result = (lease.build)(request)
if (self.cancelled)() {
raise @config.Invalid("Cancelled")
}
validate_evidence(request, lease.id, result)
if project_snapshot(project, report.artifacts) != snapshot {
raise @config.Invalid(
"build.source_changed: Source changed during validation",
)
}
evidence = Some(result)
} catch {
error => failure = Some(error_message(error))
}
(lease.dispose)() catch {
error =>
failure = Some(
failure.unwrap_or("") +
" build.cleanup_failed: " +
error_message(error),
)
}
if failure is Some(message) {
raise @config.Invalid(message)
}
if (self.cancelled)() {
raise @config.Invalid("Cancelled")
}
let evidence = evidence.unwrap()
let fields = match evidence {
Object(fields) => fields.copy()
_ => abort("checked evidence")
}
fields["profile"] = Json::string("morphir-library-build-v1")
let bytes = @ion_binary.write_binary_value(
receipt_ion(Json::object(fields)).with_annotation(
@ion.symbol("morphir_library_build"),
),
)
receipts.push(@engine.ContentArtifact::{
path: receipt,
source: project.source,
content: @data.Binary(bytes),
format: "ion-binary",
})
validated.push({
project_id: project.id,
root: project.root,
target,
source_identity,
receipt,
evidence,
})
}
// No receipt or publication appears until every required build and disposal succeeds.
for receipt in receipts {
report.artifacts.push(receipt)
}
validated
}
///|
/// Explicit process adapter. The supplied helper implements library-build-v1.
pub fn LibraryBuildProvider::process(
program : String,
helper : String,
compiler_home : String,
dependencies : Map[String, String],
timeout~ : Int32 = 120000,
) -> LibraryBuildProvider raise {
if !@platform.process_supported() ||
program == "" ||
helper == "" ||
compiler_home == "" ||
timeout < 1 ||
timeout > 600000 {
raise @config.Invalid(
"build.provider_required: Process, helper, compiler home and bounded timeout are required",
)
}
let supplied = dependencies.copy()
{
contracts: ["moonbit-library-v1"],
acquire: fn() raise {
let directory = @platform.temporary()
let request_path = directory + "/request.json"
let id = @identity.sha256(@utf8.encode(directory))
{
id,
build: fn(request) raise {
let fields = Map::from_array([
(
"timeout",
Json::number(
(if timeout < 10 { 1 } else { timeout * 9 / 10 }).to_double(),
),
),
("leaseId", Json::string(id)),
("projectId", Json::string(request.project.id)),
("contract", Json::string(request.project.build_contract)),
("snapshot", Json::string(request.snapshot)),
("sourceIdentity", Json::string(request.source_identity)),
("target", Json::string(request.target)),
("compilerHome", Json::string(compiler_home)),
(
"suppliedDependencies",
Json::object(supplied.map(fn(_, path) { Json::string(path) })),
),
(
"dependencies",
Json::array(
request.project.dependencies.map(d => {
Json::object(
Map::from_array([
("moduleName", Json::string(d.module_name)),
("version", Json::string(d.version)),
("apiProfile", Json::string(d.api_profile)),
("semanticPin", Json::string(d.semantic_pin)),
]),
)
}),
),
),
])
@platform.write(request_path, Json::object(fields).stringify())
let result = @platform.process(
program,
[helper, request_path],
@platform.cwd(),
"",
timeout,
)
if build_field(result, "exitCode") != Json::number(0.0) {
raise @config.Invalid(
(if build_field(result, "exitCode") == Json::number(124.0) {
"build.deadline: "
} else {
"build.compiler_failed: "
}) +
build_text(result, "stderr"),
)
}
let output = build_text(result, "stdout")
if output.length() > 1048576 {
raise @config.Invalid("build.output_limit")
}
@wire.parse(output)
},
dispose: fn() raise { dispose_build_tree(directory) },
}
},
}
}
///|
fn dispose_build_tree(path : String) -> Unit raise {
match @platform.kind(path) {
0 => ()
2 => {
for child in @platform.list(path) {
if child == "" ||
child == "." ||
child == ".." ||
child.contains("/") ||
child.contains("\\") {
raise @config.Invalid("build.invalid_cleanup_path")
}
dispose_build_tree(path + "/" + child)
}
@platform.remove_dir(path)
}
_ => @platform.remove_file(path)
}
}