Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion bag/lib/bag/ci_baton.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ defmodule Bag.CiBaton do
:command,
:verdict,
:exit_code,
:freeze_path,
:workdir,
artifact_path: nil,
mutating: false,
risk: :low
Expand All @@ -34,6 +36,8 @@ defmodule Bag.CiBaton do
command: [String.t()],
verdict: verdict(),
exit_code: integer() | nil,
freeze_path: String.t() | nil,
workdir: String.t() | nil,
artifact_path: String.t() | nil
}

Expand All @@ -42,7 +46,9 @@ defmodule Bag.CiBaton do

`check_id` names the check (e.g. "zig-fmt"); `command` is the argv list to run
(e.g. `["zig", "fmt", "--check", "build.zig"]`). Options: `:node` (default
"mesh-server-1") and `:required_cap` (default "linux").
"mesh-server-1"), `:required_cap` (default "linux"), `:freeze_path` (the
attested result envelope), and `:workdir` (an absolute target repository
directory; defaults to the Bag repository root).
"""
def new(check_id, command, opts \\ []) when is_binary(check_id) and is_list(command) do
%__MODULE__{
Expand All @@ -53,6 +59,8 @@ defmodule Bag.CiBaton do
command: command,
verdict: :pending,
exit_code: nil,
freeze_path: Keyword.get(opts, :freeze_path),
workdir: Keyword.get(opts, :workdir),
artifact_path: Keyword.get(opts, :artifact_path),
mutating: Keyword.get(opts, :mutating, false),
risk: Keyword.get(opts, :risk, :low)
Expand Down
45 changes: 39 additions & 6 deletions bag/lib/bag/ci_sweep.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,23 @@ defmodule Bag.CiSweep do
`required_cap` defaults to "linux". Returns `{results, summary}` where
`results` is a list of per-check verdicts and `summary` counts verdicts.
"""
alias Bag.Mesh
alias Bag.{CiBaton, Executor, GitHubBridge, Mesh}

def run(checks) when is_list(checks) do
results =
Enum.map(checks, fn check ->
cap = Map.get(check, :required_cap, "linux")
artifact_path = Map.get(check, :artifact_path)
{verdict, node, _baton} = Mesh.submit_check(check.check_id, check.command,
required_cap: cap,
artifact_path: artifact_path
)
%{check_id: check.check_id, verdict: verdict, node: node}
workdir = Map.get(check, :workdir)

{verdict, node, baton} =
Mesh.submit_check(check.check_id, check.command,
required_cap: cap,
artifact_path: artifact_path,
workdir: workdir
)

verified_result(check.check_id, verdict, node, baton)
end)

summary = Enum.frequencies_by(results, & &1.verdict)
Expand All @@ -34,4 +39,32 @@ defmodule Bag.CiSweep do
decide a green/red verdict for the whole sweep, with zero paid minutes.
"""
def all_passed?({results, _summary}), do: Enum.all?(results, &(&1.verdict == :pass))

# The signed envelope, not the in-memory execution return, authorises a green
# GitHub status. Thaw every executed Baton here so downstream reporters receive
# the verified production attestation produced by the Zig host.
defp verified_result(check_id, verdict, node, %CiBaton{freeze_path: freeze_path})
when is_binary(freeze_path) do
{thawed_verdict, thaw_output} = Executor.thaw_check(freeze_path)

effective_verdict =
if thawed_verdict == verdict do
verdict
else
:tampered
end

%{
check_id: check_id,
verdict: effective_verdict,
execution_verdict: verdict,
node: node,
freeze_path: freeze_path,
attestation: GitHubBridge.verdict_attestation(thaw_output)
}
end

defp verified_result(check_id, verdict, node, _baton) do
%{check_id: check_id, verdict: verdict, node: node}
end
end
14 changes: 11 additions & 3 deletions bag/lib/bag/executor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,23 @@ defmodule Bag.Executor do
"""
def run_check(%Bag.CiBaton{} = b, freeze_path) do
args = ["check", b.node, b.required_cap, b.check_id, freeze_path | b.command]
workdir = b.workdir || repo_root()

# Pass artifact path via env var so the Zig host can hash the report after
# the check runs and include its digest in the frozen Baton envelope.
base_opts = [cd: Path.expand("../../../", __DIR__), stderr_to_stdout: true]
base_opts = [cd: workdir, stderr_to_stdout: true]

opts =
if b.artifact_path,
do: Keyword.put(base_opts, :env, [{"BAG_ARTIFACT_PATH", b.artifact_path}]),
else: base_opts

{output, code} = System.cmd(executor_path(), args, opts)
{output, code} =
if Path.type(workdir) == :absolute and File.dir?(workdir) do
System.cmd(executor_path(), args, opts)
else
{"Bag.Executor: workdir must be an existing absolute directory: #{inspect(workdir)}", 126}
end

verdict =
case code do
Expand All @@ -81,7 +87,7 @@ defmodule Bag.Executor do
_ -> :error
end

Comment thread
hyperpolymath marked this conversation as resolved.
{verdict, %{b | verdict: verdict, exit_code: code}, output}
{verdict, %{b | verdict: verdict, exit_code: code, freeze_path: freeze_path}, output}
end

@doc """
Expand Down Expand Up @@ -153,4 +159,6 @@ defmodule Bag.Executor do
status: :floating
}
end

defp repo_root, do: Path.expand("../../../", __DIR__)
end
20 changes: 13 additions & 7 deletions bag/lib/bag/github_bridge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,9 @@ defmodule Bag.GitHubBridge do
HMAC-only verdict, can never green a required gate. Non-green verdicts
(`:fail`/`:error`/`:suspended`) need no attestation; they post as-is.

> Wiring note: until the mesh threads a per-verdict freeze path through to here
> (Phase 3, thaw-before-report), callers that cannot supply verified attestation
> will have their greens refused — the safe posture, since no live required check
> points at a bag status yet.
`Bag.CiSweep` thaws every executed envelope before reporting and threads the
parsed attestation into each result. Callers that construct result maps
themselves still fail closed when that proof is absent.

Pure argv construction (`gh_command/5`) is separated from the side-effecting
`report_check/5`, and the command runner is injectable (`:runner` opt), so the
Expand All @@ -56,7 +55,8 @@ defmodule Bag.GitHubBridge do
Options: `:description`, `:target_url` (link to the attested verdict envelope),
`:node` (folded into the default description).
"""
@spec gh_command(String.t(), String.t(), String.t(), atom(), keyword()) :: {String.t(), [String.t()]}
@spec gh_command(String.t(), String.t(), String.t(), atom(), keyword()) ::
{String.t(), [String.t()]}
def gh_command(repo, head_sha, context, verdict, opts \\ []) do
state = state_for(verdict)

Expand Down Expand Up @@ -101,7 +101,11 @@ defmodule Bag.GitHubBridge do
_ -> nil
end

ed = if String.contains?(thaw_output, "ed25519:verified"), do: :verified, else: :none
ed =
if Regex.match?(~r/(?:^|\s)ed25519:verified(?:\s|$)/m, thaw_output),
do: :verified,
else: :none

%{mode: mode, ed25519: ed}
end

Expand Down Expand Up @@ -141,7 +145,9 @@ defmodule Bag.GitHubBridge do

Returns `[{check_id, {:ok, url} | {:error, reason}}]`.
"""
@spec report_sweep({[map()], map()}, keyword()) :: [{String.t(), {:ok, String.t()} | {:error, term()}}]
@spec report_sweep({[map()], map()}, keyword()) :: [
{String.t(), {:ok, String.t()} | {:error, term()}}
]
def report_sweep({results, _summary}, context_opts) do
repo = Keyword.fetch!(context_opts, :repo)
head_sha = Keyword.fetch!(context_opts, :head_sha)
Expand Down
94 changes: 58 additions & 36 deletions bag/lib/bag/mesh.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ defmodule Bag.Mesh do
manifest, capability-matching via the Idris-to-Zig bridge), runs the check
there, and freezes an attested verdict — with ZERO GitHub Actions minutes.

Options: `:required_cap` (default "linux"), `:freeze_path`.
Options: `:required_cap` (default "linux"), `:freeze_path`, `:workdir`
(an existing absolute directory), and `:artifact_path`.
Returns `{verdict, node, baton}` where verdict is
`:pass | :fail | :suspended | :tampered | :error` (`node` is nil if suspended).
"""
Expand Down Expand Up @@ -51,34 +52,38 @@ defmodule Bag.Mesh do
defp route_baton(baton) do
# 1. Get all members in the mesh
members = :pg.get_members(:bag_mesh)

# 2. Filter members based on their node name and the Baton's requirements
# For this POC, we assume the node name is the Elixir sname (e.g. mesh-laptop)
# In a production system, this would be an attested identity.
valid_targets = Enum.filter(members, fn pid ->
node_name = node_to_name(node(pid))
# Translate requiredCap to string list for the Zig bridge
req_strings = Enum.map(baton.required_cap || [], fn
:guix -> "guix"
:linux -> "linux"
:macos -> "macos"
:gpu -> "gpu"
:trusted_host -> "trusted_host"
:zig -> "zig"
:rust -> "rust"
:cargo -> "cargo"
:deno -> "deno"
:scorecard -> "scorecard"
:wasm -> "wasm"
_ -> "unknown"
valid_targets =
Enum.filter(members, fn pid ->
node_name = node_to_name(node(pid))
# Translate requiredCap to string list for the Zig bridge
req_strings =
Enum.map(baton.required_cap || [], fn
:guix -> "guix"
:linux -> "linux"
:macos -> "macos"
:gpu -> "gpu"
:trusted_host -> "trusted_host"
:zig -> "zig"
:rust -> "rust"
:cargo -> "cargo"
:deno -> "deno"
:scorecard -> "scorecard"
:wasm -> "wasm"
:idris2 -> "idris2"
:just -> "just"
_ -> "unknown"
end)

Executor.node_satisfies?(node_name, req_strings)
end)

Executor.node_satisfies?(node_name, req_strings)
end)

IO.puts("Mesh: Routing Baton #{baton.id} [Requirements: #{inspect(baton.required_cap)}]")
IO.puts("Mesh: Valid targets found: #{inspect(valid_targets)}")

if valid_targets != [] do
target = Enum.random(valid_targets)
IO.puts("Mesh: Routing to verified node: #{inspect(target)}")
Expand All @@ -105,30 +110,45 @@ defmodule Bag.Mesh do
def handle_call({:submit_check, check_id, command, opts}, _from, state) do
required_cap = Keyword.get(opts, :required_cap, "linux")
artifact_path = Keyword.get(opts, :artifact_path)
workdir = Keyword.get(opts, :workdir)

freeze_path =
Keyword.get(opts, :freeze_path, Path.join(System.tmp_dir!(), "#{check_id}.baton"))
Comment thread
hyperpolymath marked this conversation as resolved.

# Select a capable node from the mirrored estate manifest.
capable =
Executor.list_nodes()
|> Enum.filter(fn node -> Executor.node_satisfies?(node, [required_cap]) end)
Executor.node_costs()
|> Enum.filter(fn {node, _cost} -> Executor.node_satisfies?(node, [required_cap]) end)
|> Enum.sort_by(fn {node, cost} -> {cost, node} end)

case capable do
[] ->
# Operational logs go to stderr — stdout is reserved for machine output.
IO.puts(:stderr, "Mesh: NO node satisfies '#{required_cap}' for check #{check_id}. Suspended.")
IO.puts(
:stderr,
"Mesh: NO node satisfies '#{required_cap}' for check #{check_id}. Suspended."
)

{:reply, {:suspended, nil, nil}, state}

[node | _] ->
baton = CiBaton.new(check_id, command,
node: node,
required_cap: required_cap,
artifact_path: artifact_path
)
[{node, _cost} | _] ->
baton =
CiBaton.new(check_id, command,
node: node,
required_cap: required_cap,
freeze_path: freeze_path,
workdir: workdir,
artifact_path: artifact_path
)

IO.puts(:stderr, "Mesh: routing check #{check_id} → #{node} (cap: #{required_cap})")
{verdict, updated, _output} = Executor.run_check(baton, freeze_path)
IO.puts(:stderr, "Mesh: check #{check_id} verdict=#{verdict} on #{node} (0 GitHub minutes)")

IO.puts(
:stderr,
"Mesh: check #{check_id} verdict=#{verdict} on #{node} (0 GitHub minutes)"
)

{:reply, {verdict, node, updated}, state}
end
end
Expand All @@ -138,17 +158,19 @@ defmodule Bag.Mesh do
case Planner.plan(spec, budget) do
{:ok, node, cost} ->
IO.puts(:stderr, "Mesh: planned #{spec.check_id} → #{node} (money cost #{cost})")
freeze_path = Path.join(System.tmp_dir!(), "#{spec.check_id}.baton")

baton =
CiBaton.new(spec.check_id, spec.command,
node: node,
required_cap: spec.required_cap,
mutating: Map.get(spec, :mutating, false),
risk: Map.get(spec, :risk, :low),
freeze_path: freeze_path,
workdir: Map.get(spec, :workdir),
artifact_path: Map.get(spec, :artifact_path)
)

freeze_path = Path.join(System.tmp_dir!(), "#{spec.check_id}.baton")
{verdict, updated, _output} = Executor.run_check(baton, freeze_path)
# Relegated = ran on an owned node, not GitHub's required-check route.
relegated = node != "mesh-github-runner"
Expand All @@ -168,17 +190,17 @@ defmodule Bag.Mesh do
@impl true
def handle_cast({:process, baton}, state) do
IO.puts("Mesh [#{Node.self()}]: Claimed Baton #{baton.id} with count #{baton.counter}")

case Executor.execute(baton) do
{:ok, updated_baton, output} ->
IO.puts("Mesh [#{Node.self()}]: Execution complete. Output: #{String.trim(output)}")

# Decide whether to continue or complete
if updated_baton.counter < 10 do
IO.puts("Mesh [#{Node.self()}]: Baton not finished. Passing to next step...")
# Simulate a delay
Process.sleep(1000)

# Handoff: pick a random node in the cluster
route_baton(%{updated_baton | id: baton.id})
else
Expand Down
Loading
Loading