Harden the forge build lane

65bb9b0a69df · Christopher David · · parent 948eb6301319

Harden the forge build lane

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Dockerfile
  • modified config/config.exs
  • modified config/runtime.exs
  • modified docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md
  • modified docs/beam-hot-deployment-plan.md
  • added docs/operations/forge-build-lane.md
  • modified docs/runtime-configuration.md
  • modified docs/security/secrets-and-log-handling.md
  • modified lib/openagents/accounts/token_vault.ex
  • modified lib/openagents/forge/boot_converge.ex
  • added lib/openagents/forge/build_artifact.ex
  • modified lib/openagents/forge/build_executor.ex
  • added lib/openagents/forge/build_protocol.ex
  • modified lib/openagents/forge/build_receipt.ex
  • added lib/openagents/forge/build_worker.ex
  • modified lib/openagents/forge/builder.ex
  • modified lib/openagents/forge/hot_loader.ex
  • modified lib/openagents/forge/janitor.ex
  • modified lib/openagents/forge/targets.ex
  • modified lib/openagents/forge/wal.ex
  • modified lib/openagents/work.ex
  • modified lib/openagents/work/coding.ex
  • added ops/forge/build-worker.exs
  • modified ops/staging/gate-5-profile.sh
  • added priv/repo/migrations/20260820130000_harden_forge_build_attempt_receipts.exs
  • modified test/openagents/forge/boot_converge_test.exs
  • added test/openagents/forge/build_artifact_test.exs
  • added test/openagents/forge/build_worker_test.exs
  • modified test/openagents/forge/builder_test.exs
  • modified test/openagents/forge/hot_loader_test.exs
  • modified test/openagents/forge/wal_test.exs
  • added test/support/forge/artifact_fixtures.ex
  • modified test/support/forge/fake_build_executor.ex

Diff

33 files changed, +3416 -536

Dockerfile modified +10 -1

@@ -46,6 +46,7 @@ RUN mix assets.setup

46 46
COPY priv priv
47 47
48 48
COPY lib lib
49
COPY rel rel
49 50
50 51
# Compile the release
51 52
RUN mix compile

@@ -58,9 +59,17 @@ RUN mix assets.deploy

58 59
# Changes to config/runtime.exs don't require recompiling the code
59 60
COPY config/runtime.exs config/
60 61
61
COPY rel rel
62 62
RUN mix release
63 63
64
# Isolated compiler target. Deploy this target as the forge builder sidecar;
65
# it retains the pinned production Elixir/OTP toolchain and source for the
66
# versioned queue worker, but is never used as the public web image.
67
FROM builder AS forge-builder
68
69
COPY ops/forge ops/forge
70
71
CMD ["mix", "run", "--no-compile", "--no-start", "ops/forge/build-worker.exs"]
72
64 73
# start a new build stage so that the final image will only contain
65 74
# the compiled release and other runtime necessities
66 75
FROM ${RUNNER_IMAGE} AS final
config/config.exs modified +2

@@ -192,6 +192,8 @@ config :openagents,

192 192
  forge_build_dir: "/var/lib/openagents/workspace/build",
193 193
  forge_build_queue_dir: "/var/lib/openagents/workspace/build-queue",
194 194
  forge_artifact_dir: "/var/lib/openagents/artifacts",
195
  forge_build_timeout_ms: 300_000,
196
  forge_build_output_retention_ms: 604_800_000,
195 197
  forge_artifact_store: :local,
196 198
  forge_build_executor: OpenAgents.Forge.BuildExecutor.Sidecar,
197 199
  forge_expected_fleet_size: 1,
config/runtime.exs modified +4

@@ -280,6 +280,10 @@ if config_env() == :prod do

280 280
    forge_build_dir: required_text.("OPENAGENTS_FORGE_BUILD_DIR"),
281 281
    forge_build_queue_dir: required_text.("OPENAGENTS_FORGE_BUILD_QUEUE_DIR"),
282 282
    forge_artifact_dir: required_text.("OPENAGENTS_FORGE_ARTIFACT_DIR"),
283
    forge_build_timeout_ms:
284
      parse_integer.("OPENAGENTS_FORGE_BUILD_TIMEOUT_MS", 30_000..1_800_000),
285
    forge_build_output_retention_ms:
286
      parse_integer.("OPENAGENTS_FORGE_BUILD_OUTPUT_RETENTION_MS", 86_400_000..2_592_000_000),
283 287
    forge_artifact_store: forge_artifact_store,
284 288
    forge_build_executor: forge_build_executor,
285 289
    forge_expected_fleet_size: parse_integer.("OPENAGENTS_FORGE_EXPECTED_FLEET_SIZE", 1..100),
docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md modified +56 -1

@@ -2,7 +2,7 @@

2 2
3 3
Date: 2026-08-20
4 4
5
Status: In progress; Gates 0–5 and 7–8 complete, Gate 6 application controls locally verified
5
Status: In progress; Gates 0–5 and 7–9 complete, Gate 6 application controls locally verified
6 6
7 7
## Outcome
8 8

@@ -871,6 +871,61 @@ can accept partial fleet success.

871 871
its pushed commit and immutable receipt, and malformed artifacts fail before
872 872
loading any module.
873 873
874
### Gate 9 implementation status
875
876
Completed locally on 2026-08-20:
877
878
- Replaced SHA-keyed, line-oriented queue files with strict canonical JSON
879
  requests and responses keyed by a unique UUID build attempt. Unknown fields,
880
  abbreviated SHAs, oversized bodies, malformed identities, and URLs containing
881
  credentials fail before the compiler boundary.
882
- Added atomic temporary-file publication, claim-by-rename, request expiry, and
883
  durable `running`, `complete`, `failed`, and `expired` attempt receipts. A
884
  recovered attempt always gets a new build ID, and a database constraint
885
  permits only one running attempt per target.
886
- Added the isolated `forge-builder` Docker target and
887
  `OpenAgents.Forge.BuildWorker`. It fetches and checks out the exact pushed SHA
888
  detached, uses fixed Git and Mix arguments without a shell, requires the
889
  production dependency lock, compiles with warnings as errors, and removes
890
  its disposable workspace.
891
- Kept builder credentials out of the queue, repository URL, and serving
892
  release. The worker accepts only an absolute mounted askpass-helper path and
893
  disables terminal prompting.
894
- Added deterministic BEAM normalization and canonical artifact manifests with
895
  source, baseline, Elixir, OTP, ERTS, application version/spec, and dependency
896
  lock identities plus complete added, changed, and deleted module sets.
897
- Addressed artifacts by the full tar SHA-256 in both the local cache and
898
  durable WAL store. A target cannot become `built` until independent
899
  verification, local publication, durable storage, and receipt completion all
900
  succeed.
901
- Added one shared atom-free verifier for the builder, hot loader, and boot
902
  convergence. It bounds tar, manifest, module count, BEAM size, paths, and
903
  names; checks every declared size and digest; parses `Atom` and OTP 28 `AtU8`
904
  module identity as bytes; and validates the exact change set before any
905
  module atom can be created.
906
- Routed missing baselines, deletions, NIF/native changes, dependency and
907
  application changes, assets, configuration, migrations, releases, runtime
908
  images, and toolchain drift to rolling replacement rather than direct load.
909
- Bounded the redacted compiler excerpt at 8 KiB. Full output remains a mode
910
  `0600` builder/operator artifact with a digest, reference, and seven-day
911
  default retention; the serving release does not read the full file.
912
- Added focused protocol, artifact, worker, coordinator recovery, WAL, hot-load,
913
  and boot-convergence coverage. The forge suite passes 93 tests, and the full
914
  precommit gate passes 1,302 default Elixir tests and 17 browser tests with no
915
  failures.
916
- Built the dedicated `forge-builder` Docker target with the pinned Elixir
917
  1.20.3 and OTP 29.0.5 production toolchain. The image build completed without
918
  application warnings; it also exposed and closed bitstring-size warnings that
919
  were invisible under the local Elixir 1.19 toolchain.
920
- Documented the deployment contract, permissions, exact build order,
921
  classification, verification, recovery, retention, and staging proof in the
922
  [forge build lane runbook](operations/forge-build-lane.md).
923
924
Gate 9 is complete. Keep the staging deploy lane disabled until Gate 10 makes
925
fleet application transactional. Gates 12–15 must still exercise the real
926
builder image against the exact staging SHA and retain the image, build,
927
artifact, and output-proof identities; local success is not staging admission.
928
874 929
## Gate 10: Make fleet deployment transactional
875 930
876 931
Replace one-way remote loading with prepare, apply, verify, commit, and rollback.
docs/beam-hot-deployment-plan.md modified +6

@@ -218,6 +218,11 @@ The build coordinator processes one target at a time. Every node may hear the pr

218 218
219 219
**Exit criteria:** tests cover successful builds, empty diffs, additions, deletions, compiler failure, bounded output, stale and cold manifests, artifact digest verification, worker recovery, and duplicate delivery.
220 220
221
Implementation status: Gate 9 completed this phase on 2026-08-20. See the
222
[forge build lane runbook](operations/forge-build-lane.md) for the deployed
223
contract. Fleet application remains disabled pending the transactional work in
224
Phase 5 and Gate 10.
225
221 226
## Phase 5: Add direct BEAM classification and local canary loading
222 227
223 228
Implement `OpenAgents.Forge.HotLoader` behind a disabled-by-default runtime flag.

@@ -445,6 +450,7 @@ Use runtime configuration for policy and deployment environment values. Use safe

445 450
| `forge_build_dir` | Sidecar checkout and build cache | Deployment-specific data path |
446 451
| `forge_artifact_dir` | Node-local BEAM and relup cache | Deployment-specific data path |
447 452
| `forge_build_timeout_ms` | Bounds compilation | Five minutes |
453
| `forge_build_output_retention_ms` | Expires operator-only full compiler logs | Seven days |
448 454
| `forge_fleet_rpc_timeout_ms` | Bounds each fleet operation | Fifteen seconds |
449 455
| `forge_target_repo` | Selects the deployed repository | Explicit production value |
450 456
| `forge_internal_git_url` | Gives the sidecar a canonical clone URL | Loopback or private network |
docs/operations/forge-build-lane.md added +184

@@ -0,0 +1,184 @@

1
# Forge build lane
2
3
Date: 2026-08-20
4
5
Status: Implemented locally; keep staging deployment disabled until Gate 10 is complete
6
7
## Purpose
8
9
The forge build lane turns one operator-promoted, fully qualified Git commit
10
into a reproducible BEAM artifact. Compilation runs in an isolated builder
11
container. The public release receives no compiler, Docker socket, Git
12
credential, or ability to execute queue contents.
13
14
This lane only produces and verifies artifacts. Gate 10 owns transactional
15
fleet deployment. Do not enable direct staging loads merely because a build
16
completes.
17
18
## Runtime roles
19
20
Use separate runtime identities and mounts:
21
22
| Role | Access |
23
| --- | --- |
24
| Web release | Write requests, read responses and immutable artifacts, write the node-local artifact cache, and write the configured durable WAL store |
25
| Builder | Read and claim requests, write responses and immutable artifacts, write operator-only output, create disposable workspaces, and read only the forge credential through its own identity |
26
| Operator | Read retained full output for an incident or failed-build review |
27
28
The builder gets Git credentials from a mounted executable or workload
29
identity. Set `OPENAGENTS_FORGE_GIT_ASKPASS` to the absolute path of that
30
helper. The value is a path, not a token. The worker sets
31
`GIT_TERMINAL_PROMPT=0`, never places a credential in the repository URL, and
32
invokes Git without a shell.
33
34
## Build and start the isolated worker
35
36
Build the dedicated Docker target from the same pushed revision as the web
37
image:
38
39
```sh
40
docker build --target forge-builder --tag openagents-forge-builder:<git-sha> .
41
```
42
43
The image starts this command:
44
45
```sh
46
mix run --no-compile --no-start ops/forge/build-worker.exs
47
```
48
49
Provide these absolute paths to the worker:
50
51
```text
52
OPENAGENTS_FORGE_BUILD_QUEUE_DIR
53
OPENAGENTS_FORGE_ARTIFACT_DIR
54
OPENAGENTS_FORGE_BUILD_DIR
55
```
56
57
Mount the queue and artifact roots into both roles. Mount the build root only
58
into the builder. Do not mount a Docker socket. Arrange the shared group so the
59
web release and builder can exchange mode `0640` queue files. Artifacts are
60
published mode `0444`; retained output is mode `0600` and belongs to the
61
builder/operator identity.
62
63
## Queue contract
64
65
The versioned contract is canonical JSON. Unknown fields, oversized bodies,
66
invalid repositories, abbreviated SHAs, credential-bearing URLs, and malformed
67
UUIDs fail before compilation.
68
69
```text
70
<queue>/requests/<build-id>.json
71
<queue>/running/<build-id>.json
72
<queue>/responses/<build-id>.json
73
```
74
75
The web release writes a same-directory temporary file and publishes it under
76
an exclusive lock. The worker claims a request by atomic rename into
77
`running/`. It publishes the response through the same temporary-file
78
protocol. A request contains:
79
80
- schema version;
81
- unique build ID and target ID;
82
- repository and exact 40-character source SHA;
83
- credential-free internal repository URL;
84
- the current live target's immutable manifest, or `null` for the first build;
85
- an absolute expiry time.
86
87
Every retry gets a new build ID. On restart, the coordinator expires a stale
88
`running` receipt before making a new attempt. The new attempt reads only its
89
own response filename, so a late response cannot satisfy the retry. The worker
90
also expires abandoned claimed files after their request deadline.
91
92
## Exact build procedure
93
94
The worker performs these steps in order:
95
96
1. Create `<build-root>/jobs/<build-id>` and initialize an empty Git checkout.
97
2. Add the validated credential-free internal remote.
98
3. Fetch the exact source SHA and the baseline SHA, when present.
99
4. Check out the source SHA detached and require `git rev-parse HEAD` to equal
100
   it exactly.
101
5. Compare source paths against the immutable baseline and classify dependency,
102
   configuration, asset, migration, release, runtime-image, and native-code
103
   changes.
104
6. Run `MIX_ENV=prod mix deps.get --only prod --check-locked`.
105
7. Run `MIX_ENV=prod mix compile --warnings-as-errors` in the pinned image
106
   toolchain.
107
8. Read the complete application BEAM set, normalize each BEAM, and compare it
108
   with the baseline manifest.
109
9. Remove the disposable workspace whether the build succeeds or fails.
110
111
The manifest records the source and baseline identities, build ID, Elixir,
112
OTP, ERTS, application version, application-spec digest, `mix.lock` digest,
113
all candidate module digests, and the added, changed, and deleted sets.
114
115
## Artifact format and verification
116
117
The deterministic tar contains:
118
119
```text
120
manifest.json
121
beams/Elixir.OpenAgents.<Module>.beam
122
```
123
124
Only added and changed BEAMs are included. The manifest describes the complete
125
candidate module set so deletions cannot disappear from the comparison. The
126
artifact is stored as `artifacts/<sha256>.tar` in the builder output and WAL
127
store, then cached as `beams/<sha256>.tar` on the builder node.
128
129
Before creating any module atom, the shared verifier requires all of the
130
following:
131
132
- a matching full artifact SHA-256;
133
- a tar no larger than 32 MiB;
134
- one canonical manifest no larger than 1 MiB;
135
- no more than 512 modules and no BEAM larger than 4 MiB;
136
- unique, traversal-free entry names in the declared namespace;
137
- exact repository, source SHA, and build ID;
138
- exact declared module sizes and SHA-256 digests;
139
- a BEAM-internal module name that matches the entry and manifest;
140
- an artifact entry set equal to the manifest's added and changed sets;
141
- a classification consistent with its structural reasons.
142
143
The BEAM identity parser reads `Atom` or OTP 28 `AtU8` chunks as UTF-8 bytes.
144
It does not call `binary_to_atom/2`. Module atoms are created only after the
145
entire artifact passes verification and direct-load policy.
146
147
Module deletion, a missing baseline, NIF/native changes, dependency or
148
application-spec changes, assets, configuration, migrations, releases,
149
runtime-image changes, and Elixir/OTP/ERTS toolchain drift produce
150
`needs_rolling_replace`. Gate 10 may add more conservative classifications; it
151
must not weaken this list.
152
153
## Receipts, durability, and output
154
155
`forge_builds.id` is the build ID. The coordinator inserts a `running` row
156
before queueing work. It does not mark the row `complete` or the target `built`
157
until it has independently verified the artifact, written the digest-addressed
158
local cache, and completed the durable WAL-store write.
159
160
The response carries at most 8 KiB of redacted compiler output. Receipts store
161
only that bounded excerpt plus the full-output digest and reference. The full
162
log remains under `output/<build-id>.log`, mode `0600`, and the worker deletes
163
completed logs after `OPENAGENTS_FORGE_BUILD_OUTPUT_RETENTION_MS` (seven days
164
in the admitted profile). Do not expose full output through a web route.
165
166
## Local verification
167
168
Run the focused lane and standard repository gates:
169
170
```sh
171
mix test test/openagents/forge/build_artifact_test.exs
172
mix test test/openagents/forge/build_worker_test.exs
173
mix test test/openagents/forge/builder_test.exs
174
mix test test/openagents/forge/hot_loader_test.exs
175
mix test test/openagents/forge/boot_converge_test.exs
176
mix precommit
177
```
178
179
The focused coverage proves deterministic artifacts, JSON field rejection,
180
atomic publication, exact build-ID fencing, abandoned-attempt recovery,
181
digest-addressed durable storage, log retention, tar and BEAM identity checks,
182
and malformed-artifact refusal before loading. Staging must additionally build
183
the exact pushed commit with the real sidecar image and retain the resulting
184
build receipt, artifact digest, image digest, and redacted operator log proof.
docs/runtime-configuration.md modified +2

@@ -134,6 +134,8 @@ discovery, node identity, cookie, and bounded distribution ports.

134 134
| `OPENAGENTS_FORGE_BUILD_DIR` | Absolute durable path outside `/tmp` |
135 135
| `OPENAGENTS_FORGE_BUILD_QUEUE_DIR` | Absolute durable path outside `/tmp` |
136 136
| `OPENAGENTS_FORGE_ARTIFACT_DIR` | Absolute durable path outside `/tmp` |
137
| `OPENAGENTS_FORGE_BUILD_TIMEOUT_MS` | `300000`; admitted range 30 seconds to 30 minutes |
138
| `OPENAGENTS_FORGE_BUILD_OUTPUT_RETENTION_MS` | `604800000` (seven days); admitted range one to 30 days |
137 139
| `OPENAGENTS_CODING_JOBS_DIR` | Absolute durable path outside `/tmp` when work or computers are enabled |
138 140
| `OPENAGENTS_RA_DATA_DIR` | Absolute durable path outside `/tmp` when Ra is enabled |
139 141
| `OPENAGENTS_RA_EXPECTED_SIZE` | At least `3` when Ra is enabled |
docs/security/secrets-and-log-handling.md modified +3 -1

@@ -51,7 +51,9 @@ credentials, never deployment configuration and never Secret Manager values.

51 51
  repository files, release receipts, command arguments, or repository URLs.
52 52
- The build queue contains an uncredentialed internal repository URL. The
53 53
  builder reads its forge secret through its workload identity and supplies it
54
  through `GIT_ASKPASS` with terminal prompting disabled.
54
  through the absolute helper path in `OPENAGENTS_FORGE_GIT_ASKPASS`, with
55
  terminal prompting disabled. That environment value is a path, never the
56
  secret itself.
55 57
- Keep the OAuth callback route's Phoenix dispatch logging disabled. Configure
56 58
  the external HTTPS load balancer to omit query strings for
57 59
  `/auth/github/callback`; a path and status are sufficient.
lib/openagents/accounts/token_vault.ex modified +2 -2

@@ -39,7 +39,7 @@ defmodule OpenAgents.Accounts.TokenVault do

39 39
  @spec open(binary()) :: {:ok, String.t()} | {:error, atom()}
40 40
  def open(<<@version, key_id_size, rest::binary>>)
41 41
      when key_id_size in 1..@maximum_key_id_bytes do
42
    with <<key_id::binary-size(key_id_size), nonce::binary-size(@nonce_bytes),
42
    with <<key_id::binary-size(^key_id_size), nonce::binary-size(@nonce_bytes),
43 43
           tag::binary-size(@tag_bytes), ciphertext::binary>> <- rest,
44 44
         {:ok, key} <- key_for(key_id) do
45 45
      decrypt(key, nonce, ciphertext, @aad_prefix <> key_id, tag)

@@ -76,7 +76,7 @@ defmodule OpenAgents.Accounts.TokenVault do

76 76
  def key_id(<<@version, key_id_size, rest::binary>>)
77 77
      when key_id_size in 1..@maximum_key_id_bytes do
78 78
    case rest do
79
      <<key_id::binary-size(key_id_size), _rest::binary>> -> {:ok, key_id}
79
      <<key_id::binary-size(^key_id_size), _rest::binary>> -> {:ok, key_id}
80 80
      _malformed -> {:error, :token_unsealable}
81 81
    end
82 82
  end
lib/openagents/forge/boot_converge.ex modified +66 -40

@@ -22,6 +22,8 @@ defmodule OpenAgents.Forge.BootConverge do

22 22
23 23
  require Logger
24 24
25
  alias OpenAgents.Forge.BuildArtifact
26
  alias OpenAgents.Forge.BuildProtocol
25 27
  alias OpenAgents.Forge.HotLoader
26 28
  alias OpenAgents.Forge.Repos
27 29
  alias OpenAgents.Forge.Targets

@@ -73,8 +75,20 @@ defmodule OpenAgents.Forge.BootConverge do

73 75
74 76
  defp attempt(repo) do
75 77
    case Targets.current(repo) do
76
      %{status: "live", sha: sha, details: %{"artifact" => relative}} when is_binary(relative) ->
77
        load_artifact(repo, sha, Path.join(Repos.data_dir(), relative))
78
      %{
79
        status: "live",
80
        sha: sha,
81
        details: %{
82
          "artifact" => relative,
83
          "artifact_digest" => digest,
84
          "build_id" => build_id
85
        }
86
      }
87
      when is_binary(relative) and is_binary(digest) and is_binary(build_id) ->
88
        load_artifact(repo, sha, digest, build_id, Path.join(Repos.data_dir(), relative))
89
90
      %{status: "live", details: %{"artifact" => _relative}} ->
91
        %{"state" => "image", "reason" => "artifact_identity_missing"}
78 92
79 93
      %{status: "live", sha: sha} ->
80 94
        # A live target with no artifact recorded (a no-op deploy): the

@@ -89,68 +103,80 @@ defmodule OpenAgents.Forge.BootConverge do

89 103
    end
90 104
  end
91 105
92
  defp load_artifact(repo, sha, artifact) do
106
  defp load_artifact(repo, sha, digest, build_id, artifact) do
93 107
    cond do
94 108
      not File.exists?(artifact) ->
95 109
        # The local cache misses on a replaced node — fetch the blob the
96 110
        # builder uploaded next to the WAL, then converge from it. Only if
97 111
        # the store misses too does the node boot on image code.
98
        case OpenAgents.Forge.WAL.get_artifact(repo, sha) do
112
        case OpenAgents.Forge.WAL.get_artifact(repo, digest) do
99 113
          {:ok, payload} ->
100
            # Cache the blob locally best-effort (the dir may be root-owned
101
            # on a degraded node) — convergence must not depend on it: load
102
            # straight from the fetched binary either way.
103
            with :ok <- File.mkdir_p(Path.dirname(artifact)),
104
                 :ok <- File.write(artifact, payload) do
105
              :ok
114
            with {:ok, verified} <-
115
                   BuildArtifact.verify(payload,
116
                     digest: digest,
117
                     repo: repo,
118
                     source_sha: sha,
119
                     build_id: build_id
120
                   ) do
121
              # Cache only bytes that passed the immutable identity check.
122
              _cache_result = BuildProtocol.atomic_write(artifact, payload)
123
              load_beams_from(sha, verified)
106 124
            else
107
              _cache_miss -> :ok
125
              {:error, _reason} ->
126
                %{"state" => "image", "reason" => "artifact_verification_failed"}
108 127
            end
109 128
110
            load_beams_from(sha, extract_binary!(payload))
111
112 129
          {:error, _reason} ->
113 130
            %{"state" => "image", "reason" => "artifact_missing"}
114 131
        end
115 132
116 133
      true ->
117
        load_beams_from(sha, HotLoader.extract!(artifact))
134
        case BuildArtifact.verify_file(artifact,
135
               digest: digest,
136
               repo: repo,
137
               source_sha: sha,
138
               build_id: build_id
139
             ) do
140
          {:ok, verified} ->
141
            load_beams_from(sha, verified)
142
143
          {:error, _reason} ->
144
            %{"state" => "image", "reason" => "artifact_verification_failed"}
145
        end
118 146
    end
119 147
  end
120 148
121
  defp load_beams_from(sha, beams) do
149
  defp load_beams_from(sha, verified) do
122 150
    allowlist = Application.get_env(:openagents, :forge_hot_load_allowlist, default_allowlist())
123 151
124 152
    offenders =
125
      beams
126
      |> Enum.map(fn {mod, _binary} -> to_string(mod) end)
153
      verified.modules
127 154
      |> Enum.reject(&HotLoader.allowlisted?(&1, allowlist))
128 155
129
    if offenders != [] do
130
      %{"state" => "image", "reason" => "off_allowlist:#{Enum.join(offenders, ",")}"}
131
    else
132
      failures =
133
        beams
134
        |> HotLoader.load_beams()
135
        |> Enum.reject(fn {_mod, result} -> result == :ok end)
136
137
      if failures == [] do
138
        %{"state" => "converged", "sha" => sha, "modules" => length(beams)}
139
      else
140
        %{"state" => "image", "reason" => "load_failed"}
141
      end
142
    end
143
  end
156
    cond do
157
      verified.manifest["classification"] != "direct_candidate" ->
158
        %{"state" => "image", "reason" => "artifact_not_direct"}
144 159
145
  defp extract_binary!(payload) do
146
    case :erl_tar.extract({:binary, payload}, [:memory]) do
147
      {:ok, entries} ->
148
        Enum.map(entries, fn {name, binary} ->
149
          {name |> List.to_string() |> Path.basename(".beam") |> String.to_atom(), binary}
150
        end)
160
      offenders != [] ->
161
        %{"state" => "image", "reason" => "off_allowlist:#{Enum.join(offenders, ",")}"}
151 162
152
      {:error, reason} ->
153
        raise "artifact blob extract failed: #{inspect(reason)}"
163
      true ->
164
        # Atom creation follows complete verification and policy checks.
165
        beams =
166
          Enum.map(verified.beams, fn %{module: module, binary: binary} ->
167
            {BuildArtifact.module_atom(module), binary}
168
          end)
169
170
        failures =
171
          beams
172
          |> HotLoader.load_beams()
173
          |> Enum.reject(fn {_mod, result} -> result == :ok end)
174
175
        if failures == [] do
176
          %{"state" => "converged", "sha" => sha, "modules" => length(beams)}
177
        else
178
          %{"state" => "image", "reason" => "load_failed"}
179
        end
154 180
    end
155 181
  end
156 182
lib/openagents/forge/build_artifact.ex added +726

@@ -0,0 +1,726 @@

1
defmodule OpenAgents.Forge.BuildArtifact do
2
  @moduledoc """
3
  Reproducible forge BEAM artifact creation and atom-free verification.
4
5
  Artifacts are deterministic tar files containing one canonical
6
  `manifest.json` and only the added or changed normalized BEAMs. Verification
7
  binds the tar digest, manifest identity, entry names, sizes, module count,
8
  BEAM-internal module identity, baseline, and structural classification
9
  before a caller may turn any module name into an atom.
10
  """
11
12
  alias OpenAgents.Forge.BuildProtocol
13
  alias OpenAgents.Forge.WAL
14
15
  @schema "openagents.forge.build-artifact.v1"
16
  @max_artifact_bytes 32 * 1_048_576
17
  @max_manifest_bytes 1_048_576
18
  @max_beam_bytes 4 * 1_048_576
19
  @max_modules 512
20
  @manifest_keys ~w(schema build_id repo source_sha baseline toolchain classification structural_reasons changes modules)
21
  @toolchain_keys ~w(elixir otp erts application_version application_spec_sha256 mix_lock_sha256)
22
  @change_keys ~w(added changed deleted)
23
  @module_keys ~w(name sha256 size)
24
  @classification ~w(direct_candidate needs_rolling_replace)
25
  @module_pattern ~r/^Elixir\.OpenAgents(?:\.[A-Za-z][A-Za-z0-9_]*)+$/
26
27
  @type beam :: %{module: String.t(), binary: binary()}
28
  @type verified :: %{
29
          digest: String.t(),
30
          manifest: map(),
31
          beams: [beam()],
32
          modules: [String.t()]
33
        }
34
35
  @doc "Normalize BEAM bytes by retaining only OTP-defined significant chunks."
36
  @spec normalize_beam(binary()) :: {:ok, binary()} | {:error, term()}
37
  def normalize_beam(binary) when is_binary(binary) and byte_size(binary) <= @max_beam_bytes do
38
    case :beam_lib.strip(binary) do
39
      {:ok, {_module, normalized}} when byte_size(normalized) <= @max_beam_bytes ->
40
        {:ok, normalized}
41
42
      {:ok, {_module, _too_large}} ->
43
        {:error, :beam_too_large}
44
45
      {:error, _module, reason} ->
46
        {:error, {:invalid_beam, reason}}
47
    end
48
  rescue
49
    _error -> {:error, :invalid_beam}
50
  end
51
52
  def normalize_beam(_binary), do: {:error, :beam_too_large}
53
54
  @doc "Current compiler/runtime identity recorded in every build manifest."
55
  @spec current_toolchain(keyword()) :: map()
56
  def current_toolchain(opts \\ []) do
57
    lock_path = Keyword.get(opts, :lock_path, "mix.lock")
58
    app_file = Keyword.get(opts, :app_file)
59
60
    %{
61
      "elixir" => System.version(),
62
      "otp" => System.otp_release(),
63
      "erts" => to_string(:erlang.system_info(:version)),
64
      "application_version" => application_version(app_file),
65
      "application_spec_sha256" => application_spec_digest(app_file),
66
      "mix_lock_sha256" => file_digest(lock_path)
67
    }
68
  end
69
70
  @doc "Create a deterministic verified artifact from the full candidate module set."
71
  @spec pack(String.t(), String.t(), String.t(), [beam()], keyword()) ::
72
          {:ok, %{bytes: binary(), digest: String.t(), manifest: map(), beams: [beam()]}}
73
          | {:error, term()}
74
  def pack(repo, source_sha, build_id, candidate_beams, opts \\ []) do
75
    baseline = Keyword.get(opts, :baseline_manifest)
76
    toolchain = Keyword.get(opts, :toolchain, current_toolchain())
77
    structural_reasons = Keyword.get(opts, :structural_reasons, [])
78
79
    with :ok <- WAL.validate_repo(repo),
80
         :ok <- validate_sha(source_sha),
81
         :ok <- validate_uuid(build_id),
82
         {:ok, normalized} <- normalize_candidates(candidate_beams),
83
         {:ok, baseline_modules} <- baseline_modules(baseline),
84
         {:ok, toolchain} <- validate_toolchain(toolchain),
85
         {:ok, manifest, changed_beams} <-
86
           build_manifest(
87
             repo,
88
             source_sha,
89
             build_id,
90
             normalized,
91
             baseline,
92
             baseline_modules,
93
             toolchain,
94
             structural_reasons
95
           ),
96
         {:ok, bytes} <- create_tar(manifest, changed_beams),
97
         digest = digest(bytes),
98
         {:ok, verified} <-
99
           verify(bytes,
100
             digest: digest,
101
             repo: repo,
102
             source_sha: source_sha,
103
             build_id: build_id
104
           ) do
105
      {:ok,
106
       %{
107
         bytes: bytes,
108
         digest: digest,
109
         manifest: manifest,
110
         beams: verified.beams
111
       }}
112
    end
113
  end
114
115
  @doc "Verify an artifact without creating atoms."
116
  @spec verify(binary(), keyword()) :: {:ok, verified()} | {:error, term()}
117
  def verify(bytes, opts \\ [])
118
119
  def verify(bytes, opts) when is_binary(bytes) do
120
    expected_digest = Keyword.get(opts, :digest)
121
122
    with :ok <- validate_artifact_size(bytes),
123
         actual_digest = digest(bytes),
124
         :ok <- match_expected(actual_digest, expected_digest, :artifact_digest_mismatch),
125
         {:ok, entries} <- extract_entries(bytes),
126
         {:ok, manifest_bytes, beam_entries} <- split_entries(entries),
127
         {:ok, manifest} <- decode_manifest(manifest_bytes),
128
         :ok <- validate_manifest(manifest),
129
         :ok <- validate_expected_identity(manifest, opts),
130
         {:ok, beams} <- verify_beams(beam_entries, manifest),
131
         :ok <- verify_change_entries(beams, manifest) do
132
      {:ok,
133
       %{
134
         digest: actual_digest,
135
         manifest: manifest,
136
         beams: beams,
137
         modules: Enum.map(beams, & &1.module)
138
       }}
139
    end
140
  end
141
142
  def verify(_bytes, _opts), do: {:error, :invalid_artifact}
143
144
  @doc "Read and verify a local artifact."
145
  @spec verify_file(Path.t(), keyword()) :: {:ok, verified()} | {:error, term()}
146
  def verify_file(path, opts \\ []) do
147
    with {:ok, bytes} <- File.read(path), do: verify(bytes, opts)
148
  end
149
150
  @doc "Return the module identity embedded in a BEAM without creating an atom."
151
  @spec beam_module(binary()) :: {:ok, String.t()} | {:error, term()}
152
  def beam_module(binary) when is_binary(binary) do
153
    with {:ok, beam} <- maybe_gunzip(binary),
154
         {:ok, chunks} <- beam_chunks(beam),
155
         {:ok, atom_chunk, encoding} <- atom_chunk(chunks),
156
         {:ok, module} <- first_atom(atom_chunk, encoding),
157
         true <- valid_module?(module) or {:error, :invalid_module_name} do
158
      {:ok, module}
159
    else
160
      false -> {:error, :invalid_beam_identity}
161
      {:error, _reason} = error -> error
162
    end
163
  rescue
164
    _error -> {:error, :invalid_beam_identity}
165
  end
166
167
  @doc "Create a module atom only after `verify/2` has accepted the full artifact."
168
  @spec module_atom(String.t()) :: atom()
169
  def module_atom(module) when is_binary(module) do
170
    if valid_module?(module),
171
      do: String.to_atom(module),
172
      else: raise(ArgumentError, "invalid verified module name")
173
  end
174
175
  @doc "SHA-256 digest as lowercase hexadecimal."
176
  def digest(bytes) when is_binary(bytes) do
177
    :sha256
178
    |> :crypto.hash(bytes)
179
    |> Base.encode16(case: :lower)
180
  end
181
182
  defp normalize_candidates(beams) when is_list(beams) and length(beams) <= @max_modules do
183
    beams
184
    |> Enum.reduce_while({:ok, %{}}, fn
185
      %{module: module, binary: binary}, {:ok, acc}
186
      when is_binary(module) and is_binary(binary) ->
187
        with true <- valid_module?(module) or {:error, :invalid_module_name},
188
             true <- not Map.has_key?(acc, module) or {:error, :duplicate_module},
189
             {:ok, normalized} <- normalize_beam(binary),
190
             {:ok, ^module} <- beam_module(normalized) do
191
          {:cont, {:ok, Map.put(acc, module, normalized)}}
192
        else
193
          {:error, _reason} = error -> {:halt, error}
194
          _other -> {:halt, {:error, :beam_module_mismatch}}
195
        end
196
197
      _beam, _acc ->
198
        {:halt, {:error, :invalid_beam_entry}}
199
    end)
200
  end
201
202
  defp normalize_candidates(_beams), do: {:error, :too_many_modules}
203
204
  defp build_manifest(
205
         repo,
206
         source_sha,
207
         build_id,
208
         normalized,
209
         baseline,
210
         baseline_modules,
211
         toolchain,
212
         structural_reasons
213
       ) do
214
    current_modules = Map.new(normalized, fn {module, binary} -> {module, digest(binary)} end)
215
    added = current_modules |> Map.keys() |> Enum.reject(&Map.has_key?(baseline_modules, &1))
216
217
    changed =
218
      current_modules
219
      |> Enum.filter(fn {module, module_digest} ->
220
        match?(
221
          %{^module => baseline_digest} when baseline_digest != module_digest,
222
          baseline_modules
223
        )
224
      end)
225
      |> Enum.map(&elem(&1, 0))
226
227
    deleted = baseline_modules |> Map.keys() |> Enum.reject(&Map.has_key?(current_modules, &1))
228
229
    toolchain_reasons = toolchain_reasons(baseline, toolchain)
230
231
    reasons =
232
      structural_reasons
233
      |> Enum.map(&to_string/1)
234
      |> Kernel.++(if(deleted == [], do: [], else: ["module_deletion"]))
235
      |> Kernel.++(toolchain_reasons)
236
      |> Enum.uniq()
237
      |> Enum.sort()
238
239
    classification = if reasons == [], do: "direct_candidate", else: "needs_rolling_replace"
240
241
    module_entries =
242
      normalized
243
      |> Enum.map(fn {module, binary} ->
244
        %{"name" => module, "sha256" => digest(binary), "size" => byte_size(binary)}
245
      end)
246
      |> Enum.sort_by(& &1["name"])
247
248
    manifest = %{
249
      "schema" => @schema,
250
      "build_id" => build_id,
251
      "repo" => repo,
252
      "source_sha" => source_sha,
253
      "baseline" => baseline_identity(baseline),
254
      "toolchain" => toolchain,
255
      "classification" => classification,
256
      "structural_reasons" => reasons,
257
      "changes" => %{
258
        "added" => Enum.sort(added),
259
        "changed" => Enum.sort(changed),
260
        "deleted" => Enum.sort(deleted)
261
      },
262
      "modules" => module_entries
263
    }
264
265
    changed_names = MapSet.new(added ++ changed)
266
267
    changed_beams =
268
      normalized
269
      |> Enum.filter(fn {module, _binary} -> MapSet.member?(changed_names, module) end)
270
      |> Enum.map(fn {module, binary} -> %{module: module, binary: binary} end)
271
      |> Enum.sort_by(& &1.module)
272
273
    {:ok, manifest, changed_beams}
274
  end
275
276
  defp create_tar(manifest, beams) do
277
    manifest_json = BuildProtocol.canonical_json(manifest)
278
279
    entries =
280
      [{~c"manifest.json", manifest_json}] ++
281
        Enum.map(beams, fn %{module: module, binary: binary} ->
282
          {String.to_charlist("beams/" <> module <> ".beam"), binary}
283
        end)
284
285
    path =
286
      Path.join(
287
        System.tmp_dir!(),
288
        "openagents-artifact-" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false)
289
      )
290
291
    try do
292
      with :ok <- :erl_tar.create(String.to_charlist(path), entries),
293
           {:ok, bytes} <- File.read(path),
294
           :ok <- validate_artifact_size(bytes) do
295
        {:ok, bytes}
296
      end
297
    after
298
      File.rm(path)
299
    end
300
  end
301
302
  defp extract_entries(bytes) do
303
    case :erl_tar.extract({:binary, bytes}, [:memory]) do
304
      {:ok, entries} when length(entries) <= @max_modules + 1 ->
305
        normalized = Enum.map(entries, fn {name, value} -> {to_string(name), value} end)
306
307
        if Enum.uniq_by(normalized, &elem(&1, 0)) == normalized,
308
          do: {:ok, normalized},
309
          else: {:error, :duplicate_artifact_entry}
310
311
      {:ok, _entries} ->
312
        {:error, :too_many_artifact_entries}
313
314
      {:error, reason} ->
315
        {:error, {:invalid_tar, reason}}
316
    end
317
  rescue
318
    _error -> {:error, :invalid_tar}
319
  end
320
321
  defp split_entries(entries) do
322
    case Enum.split_with(entries, fn {name, _value} -> name == "manifest.json" end) do
323
      {[{"manifest.json", manifest}], beam_entries}
324
      when byte_size(manifest) <= @max_manifest_bytes ->
325
        if Enum.all?(beam_entries, fn {name, binary} ->
326
             valid_beam_path?(name) and is_binary(binary) and byte_size(binary) <= @max_beam_bytes
327
           end) do
328
          {:ok, manifest, beam_entries}
329
        else
330
          {:error, :invalid_artifact_entry}
331
        end
332
333
      {[_manifest], _beam_entries} ->
334
        {:error, :manifest_too_large}
335
336
      _other ->
337
        {:error, :manifest_count}
338
    end
339
  end
340
341
  defp decode_manifest(bytes) do
342
    with {:ok, manifest} <- Jason.decode(bytes),
343
         true <-
344
           BuildProtocol.canonical_json(manifest) == bytes or {:error, :noncanonical_manifest} do
345
      {:ok, manifest}
346
    else
347
      {:error, _reason} = error -> error
348
      _other -> {:error, :invalid_manifest_json}
349
    end
350
  end
351
352
  defp validate_manifest(%{} = manifest) do
353
    with :ok <- exact_keys(manifest, @manifest_keys),
354
         true <- manifest["schema"] == @schema or {:error, :invalid_manifest_schema},
355
         :ok <- validate_uuid(manifest["build_id"]),
356
         :ok <- WAL.validate_repo(manifest["repo"]),
357
         :ok <- validate_sha(manifest["source_sha"]),
358
         :ok <- validate_baseline_identity(manifest["baseline"]),
359
         {:ok, _toolchain} <- validate_toolchain(manifest["toolchain"]),
360
         true <-
361
           manifest["classification"] in @classification or
362
             {:error, :invalid_classification},
363
         :ok <- validate_reasons(manifest["structural_reasons"]),
364
         :ok <- validate_changes(manifest["changes"]),
365
         :ok <- validate_modules(manifest["modules"]),
366
         :ok <- validate_classification(manifest) do
367
      :ok
368
    else
369
      false -> {:error, :invalid_manifest}
370
      {:error, _reason} = error -> error
371
    end
372
  end
373
374
  defp validate_manifest(_manifest), do: {:error, :invalid_manifest}
375
376
  defp verify_beams(entries, manifest) do
377
    metadata = Map.new(manifest["modules"], &{&1["name"], &1})
378
379
    entries
380
    |> Enum.reduce_while({:ok, []}, fn {path, binary}, {:ok, acc} ->
381
      module = path |> String.replace_prefix("beams/", "") |> String.replace_suffix(".beam", "")
382
383
      with %{} = declared <- metadata[module] || {:error, :undeclared_module},
384
           true <- declared["size"] == byte_size(binary) or {:error, :beam_size_mismatch},
385
           true <- declared["sha256"] == digest(binary) or {:error, :beam_digest_mismatch},
386
           {:ok, embedded} <- beam_module(binary),
387
           true <- embedded == module or {:error, :beam_module_mismatch} do
388
        {:cont, {:ok, [%{module: module, binary: binary} | acc]}}
389
      else
390
        {:error, _reason} = error -> {:halt, error}
391
        _other -> {:halt, {:error, :invalid_beam_entry}}
392
      end
393
    end)
394
    |> case do
395
      {:ok, beams} -> {:ok, Enum.sort_by(beams, & &1.module)}
396
      error -> error
397
    end
398
  end
399
400
  defp verify_change_entries(beams, manifest) do
401
    expected = Enum.sort(manifest["changes"]["added"] ++ manifest["changes"]["changed"])
402
    actual = Enum.map(beams, & &1.module)
403
    if actual == expected, do: :ok, else: {:error, :artifact_change_set_mismatch}
404
  end
405
406
  defp validate_expected_identity(manifest, opts) do
407
    checks = [
408
      {"repo", Keyword.get(opts, :repo)},
409
      {"source_sha", Keyword.get(opts, :source_sha)},
410
      {"build_id", Keyword.get(opts, :build_id)}
411
    ]
412
413
    Enum.reduce_while(checks, :ok, fn
414
      {_field, nil}, :ok ->
415
        {:cont, :ok}
416
417
      {field, expected}, :ok ->
418
        if manifest[field] == expected,
419
          do: {:cont, :ok},
420
          else: {:halt, {:error, {:manifest_identity_mismatch, field}}}
421
    end)
422
  end
423
424
  defp baseline_modules(nil), do: {:ok, %{}}
425
426
  defp baseline_modules(%{} = manifest) do
427
    with :ok <- validate_manifest(manifest) do
428
      {:ok, Map.new(manifest["modules"], &{&1["name"], &1["sha256"]})}
429
    end
430
  end
431
432
  defp baseline_modules(_manifest), do: {:error, :invalid_baseline_manifest}
433
434
  defp baseline_identity(nil), do: nil
435
436
  defp baseline_identity(manifest) do
437
    %{
438
      "build_id" => manifest["build_id"],
439
      "source_sha" => manifest["source_sha"],
440
      "manifest_sha256" => digest(BuildProtocol.canonical_json(manifest))
441
    }
442
  end
443
444
  defp validate_baseline_identity(nil), do: :ok
445
446
  defp validate_baseline_identity(%{} = identity) do
447
    with :ok <- exact_keys(identity, ~w(build_id source_sha manifest_sha256)),
448
         :ok <- validate_uuid(identity["build_id"]),
449
         :ok <- validate_sha(identity["source_sha"]),
450
         :ok <- validate_digest(identity["manifest_sha256"]) do
451
      :ok
452
    end
453
  end
454
455
  defp validate_baseline_identity(_identity), do: {:error, :invalid_baseline_identity}
456
457
  defp toolchain_reasons(nil, _toolchain), do: ["baseline_missing"]
458
459
  defp toolchain_reasons(%{"toolchain" => baseline}, toolchain) do
460
    @toolchain_keys
461
    |> Enum.reject(fn key -> baseline[key] == toolchain[key] end)
462
    |> Enum.map(&"toolchain_#{&1}_changed")
463
  end
464
465
  defp toolchain_reasons(_baseline, _toolchain), do: ["baseline_invalid"]
466
467
  defp validate_toolchain(%{} = toolchain) do
468
    with :ok <- exact_keys(toolchain, @toolchain_keys),
469
         true <-
470
           Enum.all?(@toolchain_keys, &valid_identity_value?(toolchain[&1])) or
471
             {:error, :invalid_toolchain} do
472
      {:ok, toolchain}
473
    end
474
  end
475
476
  defp validate_toolchain(_toolchain), do: {:error, :invalid_toolchain}
477
478
  defp valid_identity_value?(value),
479
    do: is_binary(value) and byte_size(value) in 1..256 and String.valid?(value)
480
481
  defp validate_changes(%{} = changes) do
482
    with :ok <- exact_keys(changes, @change_keys),
483
         true <-
484
           Enum.all?(@change_keys, &valid_module_list?(changes[&1])) or
485
             {:error, :invalid_changes},
486
         all = Enum.flat_map(@change_keys, &changes[&1]),
487
         true <- length(all) == length(Enum.uniq(all)) or {:error, :overlapping_changes} do
488
      :ok
489
    end
490
  end
491
492
  defp validate_changes(_changes), do: {:error, :invalid_changes}
493
494
  defp validate_modules(modules) when is_list(modules) and length(modules) <= @max_modules do
495
    result =
496
      Enum.reduce_while(modules, {:ok, []}, fn
497
        %{} = module, {:ok, names} ->
498
          with :ok <- exact_keys(module, @module_keys),
499
               true <- valid_module?(module["name"]) or {:error, :invalid_module_name},
500
               :ok <- validate_digest(module["sha256"]),
501
               true <-
502
                 (is_integer(module["size"]) and module["size"] in 1..@max_beam_bytes) or
503
                   {:error, :invalid_module_size} do
504
            {:cont, {:ok, [module["name"] | names]}}
505
          else
506
            {:error, _reason} = error -> {:halt, error}
507
          end
508
509
        _module, _acc ->
510
          {:halt, {:error, :invalid_module_metadata}}
511
      end)
512
513
    case result do
514
      {:ok, names} ->
515
        sorted = Enum.sort(names)
516
517
        cond do
518
          names != Enum.reverse(sorted) ->
519
            # The reduce accumulates in reverse, so canonical source order is
520
            # the reverse of the accumulated list.
521
            {:error, :unsorted_modules}
522
523
          length(names) != length(Enum.uniq(names)) ->
524
            {:error, :duplicate_module}
525
526
          true ->
527
            :ok
528
        end
529
530
      error ->
531
        error
532
    end
533
  end
534
535
  defp validate_modules(_modules), do: {:error, :too_many_modules}
536
537
  defp validate_reasons(reasons) when is_list(reasons) and length(reasons) <= 64 do
538
    if Enum.all?(reasons, fn reason ->
539
         is_binary(reason) and byte_size(reason) in 1..128 and
540
           Regex.match?(~r/^[a-z0-9_]+$/, reason)
541
       end) and reasons == Enum.sort(Enum.uniq(reasons)) do
542
      :ok
543
    else
544
      {:error, :invalid_structural_reasons}
545
    end
546
  end
547
548
  defp validate_reasons(_reasons), do: {:error, :invalid_structural_reasons}
549
550
  defp validate_classification(manifest) do
551
    expected =
552
      if manifest["structural_reasons"] == [],
553
        do: "direct_candidate",
554
        else: "needs_rolling_replace"
555
556
    if manifest["classification"] == expected,
557
      do: :ok,
558
      else: {:error, :classification_mismatch}
559
  end
560
561
  defp valid_module_list?(list) when is_list(list) and length(list) <= @max_modules do
562
    Enum.all?(list, &valid_module?/1) and list == Enum.sort(Enum.uniq(list))
563
  end
564
565
  defp valid_module_list?(_list), do: false
566
567
  defp valid_module?(module) when is_binary(module) and byte_size(module) <= 255,
568
    do: Regex.match?(@module_pattern, module)
569
570
  defp valid_module?(_module), do: false
571
572
  defp valid_beam_path?("beams/" <> rest = path) do
573
    module = String.replace_suffix(rest, ".beam", "")
574
575
    String.ends_with?(rest, ".beam") and path == "beams/" <> module <> ".beam" and
576
      valid_module?(module)
577
  end
578
579
  defp valid_beam_path?(_path), do: false
580
581
  defp validate_artifact_size(bytes) when byte_size(bytes) in 1..@max_artifact_bytes, do: :ok
582
  defp validate_artifact_size(_bytes), do: {:error, :artifact_too_large}
583
584
  defp validate_sha(value) when is_binary(value) do
585
    if Regex.match?(~r/^[0-9a-f]{40}$/, value), do: :ok, else: {:error, :invalid_source_sha}
586
  end
587
588
  defp validate_sha(_value), do: {:error, :invalid_source_sha}
589
590
  defp validate_uuid(value) when is_binary(value) do
591
    case Ecto.UUID.cast(value) do
592
      {:ok, _uuid} -> :ok
593
      :error -> {:error, :invalid_build_id}
594
    end
595
  end
596
597
  defp validate_uuid(_value), do: {:error, :invalid_build_id}
598
599
  defp validate_digest(value) when is_binary(value) do
600
    if Regex.match?(~r/^[0-9a-f]{64}$/, value), do: :ok, else: {:error, :invalid_digest}
601
  end
602
603
  defp validate_digest(_value), do: {:error, :invalid_digest}
604
605
  defp match_expected(_actual, nil, _reason), do: :ok
606
  defp match_expected(actual, actual, _reason), do: :ok
607
  defp match_expected(_actual, _expected, reason), do: {:error, reason}
608
609
  defp exact_keys(map, allowed) do
610
    if Map.keys(map) |> Enum.sort() == Enum.sort(allowed),
611
      do: :ok,
612
      else: {:error, :unexpected_manifest_fields}
613
  end
614
615
  defp maybe_gunzip(<<31, 139, _rest::binary>> = binary) do
616
    uncompressed = :zlib.gunzip(binary)
617
618
    if byte_size(uncompressed) <= @max_beam_bytes * 2,
619
      do: {:ok, uncompressed},
620
      else: {:error, :beam_uncompressed_too_large}
621
  rescue
622
    _error -> {:error, :invalid_gzip_beam}
623
  end
624
625
  defp maybe_gunzip(binary), do: {:ok, binary}
626
627
  defp beam_chunks(<<"FOR1", declared::32-big, "BEAM", rest::binary>>)
628
       when declared == byte_size(rest) + 4 do
629
    parse_chunks(rest, %{})
630
  end
631
632
  defp beam_chunks(_binary), do: {:error, :invalid_beam_container}
633
634
  defp parse_chunks(<<>>, chunks), do: {:ok, chunks}
635
636
  defp parse_chunks(<<id::binary-size(4), size::32-big, rest::binary>>, chunks)
637
       when size <= @max_beam_bytes do
638
    if Map.has_key?(chunks, id) do
639
      {:error, :duplicate_beam_chunk}
640
    else
641
      padded = size + rem(4 - rem(size, 4), 4)
642
      padding_size = padded - size
643
644
      if byte_size(rest) >= padded do
645
        <<chunk::binary-size(^size), _padding::binary-size(^padding_size), tail::binary>> = rest
646
        parse_chunks(tail, Map.put(chunks, id, chunk))
647
      else
648
        {:error, :truncated_beam_chunk}
649
      end
650
    end
651
  end
652
653
  defp parse_chunks(_rest, _chunks), do: {:error, :invalid_beam_chunk}
654
655
  defp atom_chunk(%{"AtU8" => chunk}), do: {:ok, chunk, :utf8}
656
  defp atom_chunk(%{"Atom" => chunk}), do: {:ok, chunk, :latin1}
657
  defp atom_chunk(_chunks), do: {:error, :missing_atom_chunk}
658
659
  defp first_atom(<<count::32-signed-big, rest::binary>>, :utf8) when count < 0 do
660
    with {:ok, length, atoms} <- compact_length(rest),
661
         true <- length in 1..255 or {:error, :invalid_atom_length},
662
         <<name::binary-size(^length), _tail::binary>> <- atoms,
663
         true <- String.valid?(name) or {:error, :invalid_utf8_atom} do
664
      {:ok, name}
665
    else
666
      {:error, _reason} = error -> error
667
      _other -> {:error, :truncated_atom_chunk}
668
    end
669
  end
670
671
  defp first_atom(<<count::32-signed-big, length, rest::binary>>, encoding) when count > 0 do
672
    with true <- length in 1..255 or {:error, :invalid_atom_length},
673
         <<name::binary-size(^length), _tail::binary>> <- rest do
674
      case encoding do
675
        :utf8 -> if String.valid?(name), do: {:ok, name}, else: {:error, :invalid_utf8_atom}
676
        :latin1 -> {:ok, :unicode.characters_to_binary(name, :latin1, :utf8)}
677
      end
678
    else
679
      {:error, _reason} = error -> error
680
      _other -> {:error, :truncated_atom_chunk}
681
    end
682
  end
683
684
  defp first_atom(_chunk, _encoding), do: {:error, :empty_atom_table}
685
686
  # OTP 28 long atom-table length encoding. Atom lengths need only the two
687
  # compact unsigned forms (0..2047), matching beam_lib's own bounded decoder.
688
  defp compact_length(<<high::4, 0::1, _tag::3, rest::binary>>), do: {:ok, high, rest}
689
690
  defp compact_length(<<high::3, 0::1, 1::1, _tag::3, low, rest::binary>>),
691
    do: {:ok, Bitwise.bor(Bitwise.bsl(high, 8), low), rest}
692
693
  defp compact_length(_binary), do: {:error, :invalid_atom_length_encoding}
694
695
  defp application_version(nil) do
696
    case Application.spec(:openagents, :vsn) do
697
      nil -> "unknown"
698
      value -> to_string(value)
699
    end
700
  end
701
702
  defp application_version(app_file) do
703
    with {:ok, contents} <- File.read(app_file),
704
         [version] <- Regex.run(~r/\{vsn,"([^"]{1,128})"\}/, contents, capture: :all_but_first) do
705
      version
706
    else
707
      _missing_or_invalid -> "unknown"
708
    end
709
  end
710
711
  defp application_spec_digest(nil) do
712
    case :code.where_is_file(~c"openagents.app") do
713
      :non_existing -> digest("missing")
714
      path -> file_digest(to_string(path))
715
    end
716
  end
717
718
  defp application_spec_digest(path), do: file_digest(path)
719
720
  defp file_digest(path) do
721
    case File.read(path) do
722
      {:ok, bytes} -> digest(bytes)
723
      {:error, _reason} -> digest("missing:" <> to_string(path))
724
    end
725
  end
726
end
lib/openagents/forge/build_executor.ex modified +172 -150

@@ -1,35 +1,37 @@

1 1
defmodule OpenAgents.Forge.BuildExecutor do
2 2
  @moduledoc """
3
  Behaviour for the forge build lane: turn a promoted `{repo, sha}` into
4
  the set of changed `.beam` binaries for that commit.
5
6
  The production adapter is `OpenAgents.Forge.BuildExecutor.Sidecar`, which
7
  talks to the `openagents-builder` sidecar container through a file queue on
8
  the shared workspace volume. Tests use `OpenAgents.Forge.FakeBuildExecutor`.
9
  The adapter is selected via the `:forge_build_executor` application env
10
  (see `OpenAgents.Forge.Builder`).
3
  Behaviour for the isolated forge build lane.
4
5
  A successful executor returns a complete, independently verified artifact;
6
  it does not return an unbound list of tar entries. Production uses the
7
  versioned JSON file protocol in `OpenAgents.Forge.BuildExecutor.Sidecar`.
11 8
  """
12 9
13
  @typedoc "One changed beam: module is the beam basename without `.beam` (`\"Elixir.Foo.Bar\"`)."
10
  @typedoc "One verified normalized BEAM, still named by a string."
14 11
  @type beam :: %{module: String.t(), binary: binary()}
15 12
16
  @typedoc "A successful build: changed beams plus bounded compiler/test output."
13
  @typedoc "A successful build and its content-addressed artifact."
17 14
  @type build_result :: %{
15
          artifact_bytes: binary(),
16
          artifact_digest: String.t(),
17
          manifest: map(),
18 18
          beams: [beam()],
19 19
          warnings: String.t(),
20 20
          tests: String.t() | nil,
21
          duration_ms: non_neg_integer()
21
          duration_ms: non_neg_integer(),
22
          output_digest: String.t() | nil,
23
          output_ref: String.t() | nil
22 24
        }
23 25
26
  @type build_error ::
27
          String.t() | %{required(:code) => String.t(), required(:output) => String.t()}
28
24 29
  @callback build(repo :: String.t(), sha :: String.t(), opts :: keyword()) ::
25
              {:ok, build_result()} | {:error, output :: String.t()}
30
              {:ok, build_result()} | {:error, build_error()}
26 31
27 32
  @max_output_bytes 8_192
28 33
29
  @doc """
30
  Bound free-form tool output (compiler/test/git) to at most `max_bytes`
31
  before it is stored in receipts or target details.
32
  """
34
  @doc "Bound and redact compiler, test, or git output before persistence."
33 35
  @spec bound_output(String.t(), pos_integer()) :: String.t()
34 36
  def bound_output(output, max_bytes \\ @max_output_bytes) when is_binary(output) do
35 37
    output = OpenAgents.LogSafety.redact(output)

@@ -44,179 +46,199 @@ end

44 46
45 47
defmodule OpenAgents.Forge.BuildExecutor.Sidecar do
46 48
  @moduledoc """
47
  Production build adapter: talks to the `openagents-builder` sidecar container
48
  through a file queue on the shared workspace volume. The OpenAgents release
49
  container runs unprivileged with no docker socket, so it cannot exec
50
  into the sidecar — the queue is the whole interface. Protocol (the
51
  watcher script lives in `ops/fleet/fleet-startup.sh`):
52
53
    * write `<sha>.job.tmp` then rename to `<sha>.job`, containing
54
      env-style `SHA=` and `REPO_URL=` lines; the URL points at the
55
      *local* forge (never GitHub) and never contains credentials. The sidecar
56
      receives its forge credential from its own runtime identity and uses an
57
      askpass helper, so no token reaches a URL, argv, or build output
58
    * the watcher clones/fetches, checks out the SHA, compiles with
59
      `MIX_ENV=prod`, diffs beams against its manifest, writes the
60
      changed-beam tar to `<data_dir>/beams/<sha>.tar`, and answers with
61
      `<sha>.result` (`STATUS=ok|error`, `MODULES=`, `DURATION=` seconds)
62
      plus the full output in `<sha>.out`
63
    * this adapter polls every 2s up to `timeout_ms` (default 300_000),
64
      reads the beams back out of the tar, and cleans up the queue files
65
66
  The pure pieces (`render_job/2`, `parse_result/1`, `beams_from_tar/1`,
67
  `module_name/1`) are public and unit-tested; the queue choreography is
68
  thin and exercised only against the real sidecar.
49
  Production adapter for the isolated forge builder.
50
51
  The serving release atomically writes a strictly validated
52
  `requests/<build-id>.json`. The sidecar claims it by rename and atomically
53
  writes `responses/<build-id>.json`; artifacts are immutable
54
  `artifacts/<sha256>.tar` objects. Credentials belong only to the builder's
55
  mounted askpass helper or workload identity and never appear in JSON, a URL,
56
  argv output, or the serving release.
69 57
  """
70 58
71 59
  @behaviour OpenAgents.Forge.BuildExecutor
72 60
73 61
  import OpenAgents.Forge.BuildExecutor, only: [bound_output: 1]
74 62
75
  alias OpenAgents.Forge.Repos
63
  alias OpenAgents.Forge.BuildArtifact
64
  alias OpenAgents.Forge.BuildProtocol
76 65
77 66
  @default_timeout_ms 300_000
78
  @poll_interval_ms 2_000
67
  @poll_interval_ms 250
79 68
80 69
  @impl true
81 70
  def build(repo, sha, opts) do
82
    timeout_ms = Keyword.get(opts, :timeout_ms, @default_timeout_ms)
83
    queue = queue_dir()
84
    File.mkdir_p!(queue)
71
    build_id = Keyword.fetch!(opts, :build_id)
72
    target_id = Keyword.fetch!(opts, :target_id)
85 73
86
    # Drop any stale answer for this sha (e.g. a previously timed-out job
87
    # that completed later) so we never read yesterday's result.
88
    File.rm(Path.join(queue, sha <> ".result"))
89
    File.rm(Path.join(queue, sha <> ".out"))
74
    timeout_ms =
75
      Keyword.get(
76
        opts,
77
        :timeout_ms,
78
        Application.get_env(:openagents, :forge_build_timeout_ms, @default_timeout_ms)
79
      )
90 80
91
    job_path = Path.join(queue, sha <> ".job")
92
    tmp_path = job_path <> ".tmp"
93
    File.write!(tmp_path, render_job(sha, repo_url(repo)))
94
    File.rename!(tmp_path, job_path)
95
96
    await_result(queue, sha, timeout_ms, timeout_ms)
81
    baseline_manifest = Keyword.get(opts, :baseline_manifest)
82
    queue = queue_dir()
83
    request_path = request_path(queue, build_id)
84
    response_path = response_path(queue, build_id)
85
86
    expires_at =
87
      DateTime.utc_now()
88
      |> DateTime.add(timeout_ms, :millisecond)
89
      |> DateTime.to_iso8601()
90
91
    request =
92
      BuildProtocol.request!(%{
93
        build_id: build_id,
94
        repo: repo,
95
        source_sha: sha,
96
        target_id: target_id,
97
        repo_url: repo_url(repo),
98
        baseline_manifest: baseline_manifest,
99
        expires_at: expires_at
100
      })
101
102
    with {:ok, encoded} <- BuildProtocol.encode_request(request),
103
         :ok <- File.mkdir_p(Path.dirname(response_path)),
104
         :ok <- BuildProtocol.atomic_write(request_path, encoded, mode: 0o640) do
105
      await_response(request, response_path, timeout_ms, timeout_ms)
106
    else
107
      {:error, reason} ->
108
        {:error, %{code: "request_write_failed", output: bound_output(inspect(reason))}}
109
    end
110
  rescue
111
    error ->
112
      {:error,
113
       %{
114
         code: "request_invalid",
115
         output: bound_output("build request invalid: " <> Exception.message(error))
116
       }}
97 117
  end
98 118
99
  # ── pure pieces (unit-tested) ───────────────────────────────────────────
100
101
  @doc "Serialize one build job (the two env-style lines the watcher sources)."
102
  @spec render_job(String.t(), String.t()) :: String.t()
103
  def render_job(sha, repo_url) do
104
    "SHA=#{sha}\nREPO_URL=#{repo_url}\n"
119
  @doc "Repository URL with no embedded credential, query, or fragment."
120
  def repo_url(repo) do
121
    base = Application.get_env(:openagents, :forge_internal_git_url, "http://127.0.0.1:8080/git")
122
    URI.to_string(URI.parse(base)) <> "/" <> repo <> ".git"
105 123
  end
106 124
107
  @doc "Parse an env-style result file (`KEY=value` per line) into a map."
108
  @spec parse_result(String.t()) :: %{String.t() => String.t()}
109
  def parse_result(contents) when is_binary(contents) do
110
    contents
111
    |> String.split("\n", trim: true)
112
    |> Enum.flat_map(fn line ->
113
      case String.split(line, "=", parts: 2) do
114
        [key, value] -> [{key, value}]
115
        _other -> []
116
      end
117
    end)
118
    |> Map.new()
125
  @doc "Queue root used by both the serving adapter and builder."
126
  def queue_dir do
127
    Application.get_env(
128
      :openagents,
129
      :forge_build_queue_dir,
130
      "/var/lib/openagents/workspace/build-queue"
131
    )
119 132
  end
120 133
121
  @doc "Read the changed-beam entries out of a beam tar's bytes, sorted by module."
122
  @spec beams_from_tar(binary()) ::
123
          {:ok, [OpenAgents.Forge.BuildExecutor.beam()]} | {:error, term()}
124
  def beams_from_tar(tar_bytes) when is_binary(tar_bytes) do
125
    case :erl_tar.extract({:binary, tar_bytes}, [:memory]) do
126
      {:ok, entries} ->
127
        {:ok,
128
         entries
129
         |> Enum.map(fn {name, binary} ->
130
           %{module: module_name(to_string(name)), binary: binary}
131
         end)
132
         |> Enum.sort_by(& &1.module)}
133
134
      {:error, reason} ->
135
        {:error, reason}
136
    end
134
  @doc "Builder-owned immutable artifact and output root."
135
  def artifact_dir do
136
    Application.get_env(:openagents, :forge_artifact_dir, "/var/lib/openagents/artifacts")
137 137
  end
138 138
139
  @doc ~S|Module name for a beam path: `"a/Elixir.Foo.Bar.beam"` -> `"Elixir.Foo.Bar"`.|
140
  @spec module_name(String.t()) :: String.t()
141
  def module_name(path) when is_binary(path) do
142
    path |> Path.basename() |> String.replace_suffix(".beam", "")
143
  end
139
  @doc false
140
  def request_path(queue, build_id), do: Path.join([queue, "requests", build_id <> ".json"])
141
142
  @doc false
143
  def response_path(queue, build_id), do: Path.join([queue, "responses", build_id <> ".json"])
144 144
145
  # ── queue choreography (thin, integration-only) ─────────────────────────
145
  defp await_response(request, _response_path, remaining_ms, timeout_ms) when remaining_ms <= 0 do
146
    File.rm(request_path(queue_dir(), request["build_id"]))
146 147
147
  defp await_result(queue, sha, remaining_ms, timeout_ms) when remaining_ms <= 0 do
148
    File.rm(Path.join(queue, sha <> ".job"))
149
    {:error, "build timed out after #{timeout_ms}ms"}
148
    {:error,
149
     %{
150
       code: "build_timeout",
151
       output: "build #{request["build_id"]} timed out after #{timeout_ms}ms"
152
     }}
150 153
  end
151 154
152
  defp await_result(queue, sha, remaining_ms, timeout_ms) do
153
    result_path = Path.join(queue, sha <> ".result")
155
  defp await_response(request, response_path, remaining_ms, timeout_ms) do
156
    case File.read(response_path) do
157
      {:ok, bytes} ->
158
        File.rm(response_path)
159
        finish(request, bytes)
154 160
155
    case File.read(result_path) do
156
      {:ok, contents} ->
157
        out = read_out(queue, sha)
158
        File.rm(result_path)
159
        File.rm(Path.join(queue, sha <> ".out"))
160
        finish(sha, parse_result(contents), out)
161
      {:error, :enoent} ->
162
        delay = min(@poll_interval_ms, remaining_ms)
163
        Process.sleep(delay)
164
        await_response(request, response_path, remaining_ms - delay, timeout_ms)
161 165
162
      {:error, _absent} ->
163
        Process.sleep(min(@poll_interval_ms, remaining_ms))
164
        await_result(queue, sha, remaining_ms - @poll_interval_ms, timeout_ms)
166
      {:error, reason} ->
167
        {:error, %{code: "response_read_failed", output: bound_output(inspect(reason))}}
165 168
    end
166 169
  end
167 170
168
  defp finish(sha, %{"STATUS" => "ok"} = result, out) do
169
    tar_path = Path.join([Repos.data_dir(), "beams", sha <> ".tar"])
170
171
    with {:ok, tar_bytes} <- File.read(tar_path),
172
         {:ok, beams} <- beams_from_tar(tar_bytes) do
173
      {:ok, %{beams: beams, warnings: out, tests: nil, duration_ms: duration_ms(result)}}
171
  defp finish(request, response_bytes) do
172
    with {:ok, response} <- BuildProtocol.decode_response(response_bytes),
173
         true <- response["build_id"] == request["build_id"] or {:error, :build_id_mismatch} do
174
      case response["status"] do
175
        "ok" ->
176
          finish_ok(request, response)
177
178
        status ->
179
          output =
180
            [response["error"] || "isolated build #{status}", response["output_excerpt"]]
181
            |> Enum.reject(&(&1 in [nil, ""]))
182
            |> Enum.join("\n")
183
184
          {:error,
185
           %{
186
             code: response["error_code"] || "build_#{status}",
187
             output: bound_output(output),
188
             duration_ms: response["duration_ms"],
189
             output_digest: response["output_digest"],
190
             output_ref: response["output_ref"]
191
           }}
192
      end
174 193
    else
175 194
      {:error, reason} ->
176
        {:error, bound_output("build ok but beam tar unreadable: #{inspect(reason)}")}
195
        {:error, %{code: "invalid_response", output: bound_output(inspect(reason))}}
177 196
    end
178 197
  end
179 198
180
  defp finish(_sha, _result, out), do: {:error, out}
181
182
  defp read_out(queue, sha) do
183
    case File.read(Path.join(queue, sha <> ".out")) do
184
      {:ok, contents} -> bound_output(contents)
185
      {:error, _absent} -> ""
199
  defp finish_ok(request, response) do
200
    artifact_path = safe_artifact_path!(response["artifact_ref"])
201
202
    with {:ok, bytes} <- File.read(artifact_path),
203
         {:ok, verified} <-
204
           BuildArtifact.verify(bytes,
205
             digest: response["artifact_digest"],
206
             repo: request["repo"],
207
             source_sha: request["source_sha"],
208
             build_id: request["build_id"]
209
           ) do
210
      {:ok,
211
       %{
212
         artifact_bytes: bytes,
213
         artifact_digest: verified.digest,
214
         manifest: verified.manifest,
215
         beams: verified.beams,
216
         warnings: bound_output(response["output_excerpt"] || ""),
217
         tests: nil,
218
         duration_ms: response["duration_ms"],
219
         output_digest: response["output_digest"],
220
         output_ref: response["output_ref"]
221
       }}
222
    else
223
      {:error, reason} ->
224
        {:error, %{code: "artifact_verification_failed", output: bound_output(inspect(reason))}}
186 225
    end
226
  rescue
227
    error ->
228
      {:error,
229
       %{
230
         code: "artifact_reference_invalid",
231
         output: bound_output(Exception.message(error))
232
       }}
187 233
  end
188 234
189
  defp duration_ms(result) do
190
    case Integer.parse(result["DURATION"] || "") do
191
      {seconds, _rest} -> seconds * 1000
192
      :error -> 0
235
  defp safe_artifact_path!("artifacts/" <> basename = ref) do
236
    if ref == "artifacts/" <> Path.basename(basename) do
237
      Path.join(artifact_dir(), ref)
238
    else
239
      raise ArgumentError, "unsafe artifact reference"
193 240
    end
194 241
  end
195 242
196
  # ── config ──────────────────────────────────────────────────────────────
197
198
  @doc """
199
  Whether this node's sidecar build workspace is warm (its incremental
200
  manifest exists). Used by the Builder's warm-node preference.
201
  """
202
  def warm? do
203
    build_dir =
204
      Application.get_env(:openagents, :forge_build_dir, "/var/lib/openagents/workspace/build")
205
206
    File.exists?(Path.join(build_dir, ".forge-manifest"))
207
  end
208
209
  defp queue_dir do
210
    Application.get_env(
211
      :openagents,
212
      :forge_build_queue_dir,
213
      "/var/lib/openagents/workspace/build-queue"
214
    )
215
  end
216
217
  @doc false
218
  def repo_url(repo) do
219
    base = Application.get_env(:openagents, :forge_internal_git_url, "http://127.0.0.1:8080/git")
220
    URI.to_string(URI.parse(base)) <> "/" <> repo <> ".git"
221
  end
243
  defp safe_artifact_path!(_ref), do: raise(ArgumentError, "invalid artifact reference")
222 244
end
lib/openagents/forge/build_protocol.ex added +338

@@ -0,0 +1,338 @@

1
defmodule OpenAgents.Forge.BuildProtocol do
2
  @moduledoc """
3
  Versioned, non-executable JSON contract between the serving release and the
4
  isolated forge builder.
5
6
  Every attempt has a UUID build ID. Queue filenames, request bodies, response
7
  bodies, and retained output references are all bound to that ID, so a late
8
  response from an abandoned attempt can never satisfy a later retry.
9
  """
10
11
  alias OpenAgents.Forge.WAL
12
13
  @request_schema "openagents.forge.build-request.v1"
14
  @response_schema "openagents.forge.build-response.v1"
15
  @max_request_bytes 1_048_576
16
  @max_response_bytes 65_536
17
  @max_error_bytes 8_192
18
  @request_keys ~w(schema build_id repo source_sha target_id repo_url baseline_manifest expires_at)
19
  @response_keys ~w(schema build_id status artifact_digest artifact_ref output_digest output_ref output_excerpt duration_ms error_code error)
20
  @statuses ~w(ok error expired)
21
22
  @type request :: map()
23
  @type response :: map()
24
25
  @doc "Build a validated request map."
26
  @spec request!(map()) :: request()
27
  def request!(attrs) when is_map(attrs) do
28
    request = %{
29
      "schema" => @request_schema,
30
      "build_id" => fetch!(attrs, :build_id),
31
      "repo" => fetch!(attrs, :repo),
32
      "source_sha" => fetch!(attrs, :source_sha),
33
      "target_id" => fetch!(attrs, :target_id),
34
      "repo_url" => fetch!(attrs, :repo_url),
35
      "baseline_manifest" => Map.get(attrs, :baseline_manifest),
36
      "expires_at" => fetch!(attrs, :expires_at)
37
    }
38
39
    case validate_request(request) do
40
      {:ok, validated} -> validated
41
      {:error, reason} -> raise ArgumentError, "invalid build request: #{inspect(reason)}"
42
    end
43
  end
44
45
  @doc "Encode a request in canonical JSON after validating it."
46
  @spec encode_request(request()) :: {:ok, binary()} | {:error, term()}
47
  def encode_request(request) do
48
    with {:ok, request} <- validate_request(request) do
49
      encoded = canonical_json(request)
50
51
      if byte_size(encoded) <= @max_request_bytes,
52
        do: {:ok, encoded},
53
        else: {:error, :request_too_large}
54
    end
55
  end
56
57
  @doc "Decode and strictly validate a request body."
58
  @spec decode_request(binary()) :: {:ok, request()} | {:error, term()}
59
  def decode_request(body) when is_binary(body) and byte_size(body) <= @max_request_bytes do
60
    with {:ok, decoded} <- Jason.decode(body), do: validate_request(decoded)
61
  end
62
63
  def decode_request(body) when is_binary(body), do: {:error, :request_too_large}
64
65
  @doc "Encode a strictly validated response in canonical JSON."
66
  @spec encode_response(response()) :: {:ok, binary()} | {:error, term()}
67
  def encode_response(response) do
68
    with {:ok, response} <- validate_response(response) do
69
      encoded = canonical_json(response)
70
71
      if byte_size(encoded) <= @max_response_bytes,
72
        do: {:ok, encoded},
73
        else: {:error, :response_too_large}
74
    end
75
  end
76
77
  @doc "Decode and strictly validate a response body."
78
  @spec decode_response(binary()) :: {:ok, response()} | {:error, term()}
79
  def decode_response(body) when is_binary(body) and byte_size(body) <= @max_response_bytes do
80
    with {:ok, decoded} <- Jason.decode(body), do: validate_response(decoded)
81
  end
82
83
  def decode_response(body) when is_binary(body), do: {:error, :response_too_large}
84
85
  @doc "Strictly validate a request map and reject unknown fields."
86
  @spec validate_request(term()) :: {:ok, request()} | {:error, term()}
87
  def validate_request(%{} = request) do
88
    with :ok <- exact_keys(request, @request_keys),
89
         true <- request["schema"] == @request_schema or {:error, :invalid_schema},
90
         :ok <- validate_uuid(request["build_id"], :build_id),
91
         :ok <- WAL.validate_repo(request["repo"]),
92
         :ok <- validate_sha(request["source_sha"]),
93
         :ok <- validate_uuid(request["target_id"], :target_id),
94
         :ok <- validate_repo_url(request["repo_url"]),
95
         :ok <- validate_baseline(request["baseline_manifest"]),
96
         :ok <- validate_expiry(request["expires_at"]) do
97
      {:ok, request}
98
    else
99
      {:error, _reason} = error -> error
100
    end
101
  end
102
103
  def validate_request(_request), do: {:error, :invalid_request}
104
105
  @doc "Strictly validate a response map and reject unknown fields."
106
  @spec validate_response(term()) :: {:ok, response()} | {:error, term()}
107
  def validate_response(%{} = response) do
108
    with :ok <- exact_keys(response, @response_keys),
109
         true <- response["schema"] == @response_schema or {:error, :invalid_schema},
110
         :ok <- validate_uuid(response["build_id"], :build_id),
111
         true <- response["status"] in @statuses or {:error, :invalid_status},
112
         :ok <- validate_response_fields(response) do
113
      {:ok, response}
114
    else
115
      {:error, _reason} = error -> error
116
    end
117
  end
118
119
  def validate_response(_response), do: {:error, :invalid_response}
120
121
  @doc "Create a successful response map."
122
  def ok_response(build_id, attrs) do
123
    response_base(build_id, "ok")
124
    |> Map.merge(%{
125
      "artifact_digest" => fetch!(attrs, :artifact_digest),
126
      "artifact_ref" => fetch!(attrs, :artifact_ref),
127
      "output_digest" => fetch!(attrs, :output_digest),
128
      "output_ref" => fetch!(attrs, :output_ref),
129
      "output_excerpt" => Map.get(attrs, :output_excerpt, ""),
130
      "duration_ms" => fetch!(attrs, :duration_ms)
131
    })
132
  end
133
134
  @doc "Create an error or expiry response map."
135
  def error_response(build_id, status, error_code, error, attrs \\ %{})
136
      when status in ~w(error expired) do
137
    response_base(build_id, status)
138
    |> Map.merge(%{
139
      "output_digest" => Map.get(attrs, :output_digest),
140
      "output_ref" => Map.get(attrs, :output_ref),
141
      "output_excerpt" => Map.get(attrs, :output_excerpt, ""),
142
      "duration_ms" => Map.get(attrs, :duration_ms, 0),
143
      "error_code" => error_code,
144
      "error" => String.slice(to_string(error), 0, @max_error_bytes)
145
    })
146
  end
147
148
  @doc "Write bytes through a same-directory temporary file and atomic rename."
149
  @spec atomic_write(Path.t(), iodata(), keyword()) :: :ok | {:error, term()}
150
  def atomic_write(path, contents, opts \\ []) do
151
    mode = Keyword.get(opts, :mode, 0o600)
152
    tmp = path <> ".tmp-" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false)
153
    lock = path <> ".publish-lock"
154
155
    with :ok <- File.mkdir_p(Path.dirname(path)),
156
         :ok <- File.write(tmp, contents, [:binary]),
157
         :ok <- File.chmod(tmp, mode),
158
         {:ok, lock_io} <- File.open(lock, [:write, :exclusive]) do
159
      try do
160
        if File.exists?(path),
161
          do: {:error, :destination_exists},
162
          else: File.rename(tmp, path)
163
      after
164
        File.close(lock_io)
165
        File.rm(lock)
166
      end
167
    else
168
      {:error, reason} = error ->
169
        File.rm(tmp)
170
        if reason == :eexist, do: {:error, :destination_exists}, else: error
171
    end
172
    |> tap(fn _result -> File.rm(tmp) end)
173
  end
174
175
  @doc "Canonical JSON used by signed/digested protocol documents."
176
  @spec canonical_json(term()) :: binary()
177
  def canonical_json(term) do
178
    term
179
    |> ordered()
180
    |> Jason.encode!()
181
  end
182
183
  defp response_base(build_id, status) do
184
    %{
185
      "schema" => @response_schema,
186
      "build_id" => build_id,
187
      "status" => status,
188
      "artifact_digest" => nil,
189
      "artifact_ref" => nil,
190
      "output_digest" => nil,
191
      "output_ref" => nil,
192
      "output_excerpt" => "",
193
      "duration_ms" => 0,
194
      "error_code" => nil,
195
      "error" => nil
196
    }
197
  end
198
199
  defp validate_response_fields(%{"status" => "ok"} = response) do
200
    with :ok <- validate_digest(response["artifact_digest"]),
201
         :ok <- validate_ref(response["artifact_ref"], "artifacts/", ".tar"),
202
         :ok <- validate_optional_digest(response["output_digest"]),
203
         :ok <- validate_optional_ref(response["output_ref"], "output/", ".log"),
204
         :ok <- validate_excerpt(response["output_excerpt"]),
205
         :ok <- validate_duration(response["duration_ms"]),
206
         true <-
207
           (is_nil(response["error_code"]) and is_nil(response["error"])) or
208
             {:error, :unexpected_error_fields} do
209
      :ok
210
    end
211
  end
212
213
  defp validate_response_fields(response) do
214
    with true <-
215
           (is_nil(response["artifact_digest"]) and is_nil(response["artifact_ref"])) or
216
             {:error, :unexpected_artifact_fields},
217
         :ok <- validate_optional_digest(response["output_digest"]),
218
         :ok <- validate_optional_ref(response["output_ref"], "output/", ".log"),
219
         :ok <- validate_excerpt(response["output_excerpt"]),
220
         :ok <- validate_duration(response["duration_ms"]),
221
         :ok <- validate_error_code(response["error_code"]),
222
         :ok <- validate_error(response["error"]) do
223
      :ok
224
    end
225
  end
226
227
  defp validate_baseline(nil), do: :ok
228
229
  defp validate_baseline(%{} = manifest) do
230
    if byte_size(canonical_json(manifest)) <= @max_request_bytes,
231
      do: :ok,
232
      else: {:error, :baseline_too_large}
233
  rescue
234
    _error -> {:error, :invalid_baseline}
235
  end
236
237
  defp validate_baseline(_manifest), do: {:error, :invalid_baseline}
238
239
  defp validate_repo_url(url) when is_binary(url) and byte_size(url) <= 2_048 do
240
    uri = URI.parse(url)
241
242
    if uri.scheme in ["http", "https"] and is_binary(uri.host) and is_nil(uri.userinfo) and
243
         is_nil(uri.query) and is_nil(uri.fragment) and String.ends_with?(uri.path || "", ".git") do
244
      :ok
245
    else
246
      {:error, :invalid_repo_url}
247
    end
248
  end
249
250
  defp validate_repo_url(_url), do: {:error, :invalid_repo_url}
251
252
  defp validate_expiry(value) when is_binary(value) do
253
    case DateTime.from_iso8601(value) do
254
      {:ok, _datetime, 0} -> :ok
255
      _other -> {:error, :invalid_expiry}
256
    end
257
  end
258
259
  defp validate_expiry(_value), do: {:error, :invalid_expiry}
260
261
  defp validate_sha(value) when is_binary(value) do
262
    if Regex.match?(~r/^[0-9a-f]{40}$/, value), do: :ok, else: {:error, :invalid_source_sha}
263
  end
264
265
  defp validate_sha(_value), do: {:error, :invalid_source_sha}
266
267
  defp validate_uuid(value, field) when is_binary(value) do
268
    case Ecto.UUID.cast(value) do
269
      {:ok, _uuid} -> :ok
270
      :error -> {:error, {:invalid_uuid, field}}
271
    end
272
  end
273
274
  defp validate_uuid(_value, field), do: {:error, {:invalid_uuid, field}}
275
276
  defp validate_digest(value) when is_binary(value) do
277
    if Regex.match?(~r/^[0-9a-f]{64}$/, value), do: :ok, else: {:error, :invalid_digest}
278
  end
279
280
  defp validate_digest(_value), do: {:error, :invalid_digest}
281
282
  defp validate_optional_digest(nil), do: :ok
283
  defp validate_optional_digest(value), do: validate_digest(value)
284
285
  defp validate_ref(value, prefix, suffix) when is_binary(value) do
286
    basename = Path.basename(value)
287
288
    if value == prefix <> basename and String.ends_with?(basename, suffix) and
289
         not String.contains?(value, ["..", "\\"]) do
290
      :ok
291
    else
292
      {:error, :invalid_ref}
293
    end
294
  end
295
296
  defp validate_ref(_value, _prefix, _suffix), do: {:error, :invalid_ref}
297
  defp validate_optional_ref(nil, _prefix, _suffix), do: :ok
298
  defp validate_optional_ref(value, prefix, suffix), do: validate_ref(value, prefix, suffix)
299
300
  defp validate_duration(value) when is_integer(value) and value >= 0 and value <= 86_400_000,
301
    do: :ok
302
303
  defp validate_duration(_value), do: {:error, :invalid_duration}
304
305
  defp validate_error_code(value) when is_binary(value) and byte_size(value) in 1..128 do
306
    if Regex.match?(~r/^[a-z0-9_]+$/, value), do: :ok, else: {:error, :invalid_error_code}
307
  end
308
309
  defp validate_error_code(_value), do: {:error, :invalid_error_code}
310
311
  defp validate_error(value) when is_binary(value) and byte_size(value) <= @max_error_bytes,
312
    do: :ok
313
314
  defp validate_error(_value), do: {:error, :invalid_error}
315
316
  defp validate_excerpt(value) when is_binary(value) and byte_size(value) <= @max_error_bytes,
317
    do: :ok
318
319
  defp validate_excerpt(_value), do: {:error, :invalid_output_excerpt}
320
321
  defp exact_keys(map, allowed) do
322
    if Map.keys(map) |> Enum.sort() == Enum.sort(allowed),
323
      do: :ok,
324
      else: {:error, :unexpected_fields}
325
  end
326
327
  defp fetch!(map, key), do: Map.fetch!(map, key)
328
329
  defp ordered(%{} = map) when not is_struct(map) do
330
    map
331
    |> Enum.sort_by(fn {key, _value} -> to_string(key) end)
332
    |> Enum.map(fn {key, value} -> {to_string(key), ordered(value)} end)
333
    |> Jason.OrderedObject.new()
334
  end
335
336
  defp ordered(list) when is_list(list), do: Enum.map(list, &ordered/1)
337
  defp ordered(value), do: value
338
end
lib/openagents/forge/build_receipt.ex modified +87 -7

@@ -1,9 +1,10 @@

1 1
defmodule OpenAgents.Forge.BuildReceipt do
2 2
  @moduledoc """
3
  Derived record of one build in the forge deploy lane (`forge_builds`).
4
  Idempotent by `{repo, sha, target_id}`; the artifact tar on disk plus
5
  the target row carry the operational truth — this is the audit receipt
6
  (sha, changed modules, bounded compiler/test output, duration).
3
  Durable record of one uniquely identified build attempt in the forge deploy
4
  lane (`forge_builds`). The row exists before the sidecar receives work and
5
  advances through `running` to `complete`, `failed`, or `expired`. A process
6
  restart expires the abandoned build ID before creating a new attempt, so a
7
  late response can never be mistaken for the retry.
7 8
  """
8 9
9 10
  use Ecto.Schema

@@ -16,18 +17,97 @@ defmodule OpenAgents.Forge.BuildReceipt do

16 17
    field :repo, :string
17 18
    field :sha, :string
18 19
    field :target_id, :binary_id
20
    field :status, :string, default: "running"
21
    field :baseline_manifest, :map
22
    field :manifest, :map
19 23
    field :modules, {:array, :string}, default: []
20 24
    field :warnings, :string
21 25
    field :tests, :string
22 26
    field :duration_ms, :integer
23 27
    field :artifact, :string
24
    timestamps(updated_at: false)
28
    field :artifact_digest, :string
29
    field :output_digest, :string
30
    field :output_ref, :string
31
    field :error_code, :string
32
    field :completed_at, :utc_datetime_usec
33
    timestamps()
25 34
  end
26 35
36
  @doc "Create the durable `running` row before handing work to the sidecar."
37
  def start_changeset(receipt, attrs) do
38
    receipt
39
    |> cast(attrs, [:repo, :sha, :target_id, :baseline_manifest])
40
    |> put_change(:status, "running")
41
    |> validate_required([:repo, :sha, :target_id, :status])
42
    |> validate_format(:sha, ~r/^[0-9a-f]{40}$/)
43
    |> unique_constraint(:target_id, name: :forge_builds_one_running_attempt_per_target)
44
  end
45
46
  @doc "Complete a running attempt with its immutable verified manifest."
47
  def complete_changeset(receipt, attrs) do
48
    receipt
49
    |> cast(attrs, [
50
      :manifest,
51
      :modules,
52
      :warnings,
53
      :tests,
54
      :duration_ms,
55
      :artifact,
56
      :artifact_digest,
57
      :output_digest,
58
      :output_ref
59
    ])
60
    |> put_change(:status, "complete")
61
    |> put_change(:completed_at, DateTime.utc_now())
62
    |> validate_required([:manifest, :modules, :artifact, :artifact_digest, :duration_ms])
63
    |> validate_format(:artifact_digest, ~r/^[0-9a-f]{64}$/)
64
    |> validate_optional_digest(:output_digest)
65
  end
66
67
  @doc "Close a running attempt as failed or expired."
68
  def terminal_changeset(receipt, status, attrs) when status in ~w(failed expired) do
69
    receipt
70
    |> cast(attrs, [:warnings, :duration_ms, :output_digest, :output_ref, :error_code])
71
    |> put_change(:status, status)
72
    |> put_change(:completed_at, DateTime.utc_now())
73
    |> validate_required([:error_code])
74
    |> validate_format(:error_code, ~r/^[a-z0-9_]{1,128}$/)
75
    |> validate_optional_digest(:output_digest)
76
  end
77
78
  # Compatibility for receipt fixtures and changelog tests. Runtime builds use
79
  # the explicit lifecycle changesets above.
27 80
  def changeset(receipt, attrs) do
81
    status = Map.get(attrs, :status, Map.get(attrs, "status", "complete"))
82
28 83
    receipt
29
    |> cast(attrs, [:repo, :sha, :target_id, :modules, :warnings, :tests, :duration_ms, :artifact])
84
    |> cast(attrs, [
85
      :repo,
86
      :sha,
87
      :target_id,
88
      :status,
89
      :baseline_manifest,
90
      :manifest,
91
      :modules,
92
      :warnings,
93
      :tests,
94
      :duration_ms,
95
      :artifact,
96
      :artifact_digest,
97
      :output_digest,
98
      :output_ref,
99
      :error_code,
100
      :completed_at
101
    ])
102
    |> put_change(:status, status)
30 103
    |> validate_required([:repo, :sha, :target_id])
31
    |> unique_constraint([:repo, :sha, :target_id])
104
    |> validate_inclusion(:status, ~w(running complete failed expired))
105
  end
106
107
  defp validate_optional_digest(changeset, field) do
108
    case get_field(changeset, field) do
109
      nil -> changeset
110
      _value -> validate_format(changeset, field, ~r/^[0-9a-f]{64}$/)
111
    end
32 112
  end
33 113
end
lib/openagents/forge/build_worker.ex added +608

@@ -0,0 +1,608 @@

1
defmodule OpenAgents.Forge.BuildWorker do
2
  @moduledoc """
3
  Isolated sidecar worker for versioned forge build requests.
4
5
  This module runs in the builder container, not in the serving release. It
6
  claims requests by atomic rename, checks out the exact pushed commit in a
7
  fresh workspace, invokes the pinned production toolchain without a shell,
8
  retains full output in an operator-only file, and publishes a verified
9
  content-addressed artifact and response through atomic renames.
10
  """
11
12
  alias OpenAgents.Forge.BuildArtifact
13
  alias OpenAgents.Forge.BuildProtocol
14
15
  @poll_ms 250
16
  @max_command_excerpt 8_192
17
  @default_output_retention_ms 7 * 24 * 60 * 60 * 1000
18
19
  @doc "Run the worker forever. Intended as the sidecar container entrypoint."
20
  def run do
21
    queue = required_env!("OPENAGENTS_FORGE_BUILD_QUEUE_DIR")
22
    artifacts = required_env!("OPENAGENTS_FORGE_ARTIFACT_DIR")
23
    builds = required_env!("OPENAGENTS_FORGE_BUILD_DIR")
24
    ensure_builder_paths!(queue, artifacts, builds)
25
    loop(queue, artifacts, builds)
26
  end
27
28
  @doc "Claim and process at most one request. Public for protocol integration tests."
29
  def run_once(queue, artifacts, builds, opts \\ []) do
30
    ensure_builder_paths!(queue, artifacts, builds)
31
    expire_outputs(artifacts, opts)
32
    expire_abandoned(queue)
33
34
    queue
35
    |> request_files()
36
    |> Enum.find_value(:idle, fn request_path ->
37
      case claim(request_path, queue) do
38
        {:ok, running_path} ->
39
          process_claim(running_path, queue, artifacts, builds, opts)
40
          :processed
41
42
        :lost_race ->
43
          false
44
      end
45
    end)
46
  end
47
48
  defp loop(queue, artifacts, builds) do
49
    case run_once(queue, artifacts, builds) do
50
      :idle -> Process.sleep(@poll_ms)
51
      :processed -> :ok
52
    end
53
54
    loop(queue, artifacts, builds)
55
  end
56
57
  defp process_claim(running_path, queue, artifacts, builds, opts) do
58
    started = System.monotonic_time(:millisecond)
59
60
    response =
61
      with {:ok, request_bytes} <- File.read(running_path),
62
           {:ok, request} <- BuildProtocol.decode_request(request_bytes),
63
           :ok <- ensure_not_expired(request) do
64
        execute(request, artifacts, builds, started, opts)
65
      else
66
        {:error, reason} ->
67
          build_id = build_id_from_path(running_path)
68
          BuildProtocol.error_response(build_id, "error", error_code(reason), inspect(reason))
69
      end
70
71
    write_response(queue, response)
72
  after
73
    File.rm(running_path)
74
  end
75
76
  defp execute(request, artifacts, builds, started, opts) do
77
    build_id = request["build_id"]
78
    workspace = Path.join([builds, "jobs", build_id])
79
    output_tmp = Path.join([artifacts, "output", ".#{build_id}.tmp"])
80
81
    try do
82
      File.rm_rf(workspace)
83
      File.mkdir_p!(workspace)
84
      File.mkdir_p!(Path.dirname(output_tmp))
85
      File.write!(output_tmp, "", [:binary])
86
      File.chmod!(output_tmp, 0o600)
87
88
      result =
89
        with {:ok, beams, toolchain, structural_reasons} <-
90
               prepare_candidate(request, workspace, builds, output_tmp, opts),
91
             {:ok, artifact} <-
92
               BuildArtifact.pack(
93
                 request["repo"],
94
                 request["source_sha"],
95
                 build_id,
96
                 beams,
97
                 baseline_manifest: request["baseline_manifest"],
98
                 toolchain: toolchain,
99
                 structural_reasons: structural_reasons
100
               ),
101
             :ok <- store_artifact(artifacts, artifact) do
102
          {:ok, artifact}
103
        end
104
105
      {output_ref, output_digest} = finalize_output!(artifacts, build_id, output_tmp)
106
      output_excerpt = output_excerpt(artifacts, output_ref)
107
      duration_ms = System.monotonic_time(:millisecond) - started
108
109
      case result do
110
        {:ok, artifact} ->
111
          BuildProtocol.ok_response(build_id, %{
112
            artifact_digest: artifact.digest,
113
            artifact_ref: "artifacts/#{artifact.digest}.tar",
114
            output_digest: output_digest,
115
            output_ref: output_ref,
116
            output_excerpt: output_excerpt,
117
            duration_ms: duration_ms
118
          })
119
120
        {:error, reason} ->
121
          BuildProtocol.error_response(build_id, "error", error_code(reason), inspect(reason), %{
122
            output_digest: output_digest,
123
            output_ref: output_ref,
124
            output_excerpt: output_excerpt,
125
            duration_ms: duration_ms
126
          })
127
      end
128
    rescue
129
      error ->
130
        duration_ms = System.monotonic_time(:millisecond) - started
131
132
        {output_ref, output_digest} =
133
          finalize_output_after_crash(
134
            artifacts,
135
            build_id,
136
            output_tmp,
137
            Exception.format(:error, error)
138
          )
139
140
        output_excerpt = output_excerpt(artifacts, output_ref)
141
142
        BuildProtocol.error_response(
143
          build_id,
144
          "error",
145
          "worker_crashed",
146
          Exception.message(error),
147
          %{
148
            output_digest: output_digest,
149
            output_ref: output_ref,
150
            output_excerpt: output_excerpt,
151
            duration_ms: duration_ms
152
          }
153
        )
154
    after
155
      File.rm_rf(workspace)
156
    end
157
  end
158
159
  defp prepare_candidate(request, workspace, builds, output, opts) do
160
    case Keyword.get(opts, :build_fun) do
161
      build_fun when is_function(build_fun, 2) ->
162
        case build_fun.(request, workspace) do
163
          {:ok, beams, toolchain, structural_reasons, retained_output} ->
164
            File.write!(output, retained_output, [:append, :binary])
165
            {:ok, beams, toolchain, structural_reasons}
166
167
          {:error, _reason} = error ->
168
            error
169
        end
170
171
      nil ->
172
        prepare_production_candidate(request, workspace, builds, output, opts)
173
174
      _invalid ->
175
        {:error, :invalid_build_fun}
176
    end
177
  end
178
179
  defp prepare_production_candidate(request, workspace, builds, output, opts) do
180
    with {:ok, env} <- command_env(opts),
181
         :ok <- run_ok("git", ["init", "--quiet", workspace], builds, env, output),
182
         :ok <-
183
           run_ok(
184
             "git",
185
             ["remote", "add", "origin", request["repo_url"]],
186
             workspace,
187
             env,
188
             output
189
           ),
190
         :ok <- fetch_source(request, workspace, env, output),
191
         :ok <- checkout_exact(request, workspace, env, output),
192
         {:ok, structural_reasons} <- source_classification(request, workspace, env, output),
193
         :ok <-
194
           run_ok(
195
             "mix",
196
             ["deps.get", "--only", "prod", "--check-locked"],
197
             workspace,
198
             [{"MIX_ENV", "prod"} | env],
199
             output
200
           ),
201
         :ok <-
202
           run_ok(
203
             "mix",
204
             ["compile", "--warnings-as-errors"],
205
             workspace,
206
             [{"MIX_ENV", "prod"} | env],
207
             output
208
           ),
209
         {:ok, beams} <- read_candidate_beams(workspace) do
210
      toolchain =
211
        BuildArtifact.current_toolchain(
212
          lock_path: Path.join(workspace, "mix.lock"),
213
          app_file: Path.join(workspace, "_build/prod/lib/openagents/ebin/openagents.app")
214
        )
215
216
      {:ok, beams, toolchain, structural_reasons}
217
    end
218
  end
219
220
  defp fetch_source(request, workspace, env, output) do
221
    baseline_sha = get_in(request, ["baseline_manifest", "source_sha"])
222
223
    with :ok <-
224
           run_ok(
225
             "git",
226
             ["fetch", "--no-tags", "--depth=1", "origin", request["source_sha"]],
227
             workspace,
228
             env,
229
             output
230
           ),
231
         :ok <- fetch_baseline(baseline_sha, request["source_sha"], workspace, env, output) do
232
      :ok
233
    end
234
  end
235
236
  defp fetch_baseline(nil, _source_sha, _workspace, _env, _output), do: :ok
237
  defp fetch_baseline(source_sha, source_sha, _workspace, _env, _output), do: :ok
238
239
  defp fetch_baseline(baseline_sha, _source_sha, workspace, env, output) do
240
    run_ok(
241
      "git",
242
      ["fetch", "--no-tags", "--depth=1", "origin", baseline_sha],
243
      workspace,
244
      env,
245
      output
246
    )
247
  end
248
249
  defp checkout_exact(request, workspace, env, output) do
250
    with :ok <-
251
           run_ok(
252
             "git",
253
             ["checkout", "--quiet", "--detach", request["source_sha"]],
254
             workspace,
255
             env,
256
             output
257
           ),
258
         {:ok, actual} <- run_command("git", ["rev-parse", "HEAD"], workspace, env, output),
259
         true <- String.trim(actual) == request["source_sha"] or {:error, :checkout_mismatch} do
260
      :ok
261
    end
262
  end
263
264
  defp source_classification(%{"baseline_manifest" => nil}, _workspace, _env, _output),
265
    do: {:ok, ["baseline_missing"]}
266
267
  defp source_classification(request, workspace, env, output) do
268
    baseline_sha = request["baseline_manifest"]["source_sha"]
269
270
    with {:ok, diff} <-
271
           run_command(
272
             "git",
273
             ["diff", "--name-only", "#{baseline_sha}..#{request["source_sha"]}"],
274
             workspace,
275
             env,
276
             output
277
           ) do
278
      reasons =
279
        diff
280
        |> String.split("\n", trim: true)
281
        |> Enum.flat_map(&structural_reason/1)
282
        |> Enum.uniq()
283
        |> Enum.sort()
284
285
      {:ok, reasons}
286
    end
287
  end
288
289
  defp structural_reason("mix.lock"), do: ["dependency_lock_changed"]
290
  defp structural_reason("mix.exs"), do: ["dependency_definition_changed"]
291
  defp structural_reason("Dockerfile"), do: ["runtime_image_changed"]
292
  defp structural_reason("Dockerfile." <> _suffix), do: ["runtime_image_changed"]
293
  defp structural_reason("config/" <> _path), do: ["config_changed"]
294
  defp structural_reason("assets/" <> _path), do: ["assets_changed"]
295
  defp structural_reason("priv/static/" <> _path), do: ["assets_changed"]
296
  defp structural_reason("priv/repo/migrations/" <> _path), do: ["migration_changed"]
297
  defp structural_reason("rel/" <> _path), do: ["release_changed"]
298
  defp structural_reason("native/" <> _path), do: ["nif_changed"]
299
  defp structural_reason("c_src/" <> _path), do: ["nif_changed"]
300
301
  defp structural_reason(path) do
302
    cond do
303
      String.ends_with?(path, [".so", ".nif", ".dll", ".dylib"]) -> ["nif_changed"]
304
      true -> []
305
    end
306
  end
307
308
  defp read_candidate_beams(workspace) do
309
    paths = Path.wildcard(Path.join(workspace, "_build/prod/lib/openagents/ebin/*.beam"))
310
311
    if paths == [] do
312
      {:error, :application_beams_missing}
313
    else
314
      paths
315
      |> Enum.reduce_while({:ok, []}, fn path, {:ok, acc} ->
316
        module = Path.basename(path, ".beam")
317
318
        case File.read(path) do
319
          {:ok, binary} -> {:cont, {:ok, [%{module: module, binary: binary} | acc]}}
320
          {:error, reason} -> {:halt, {:error, {:beam_read_failed, reason}}}
321
        end
322
      end)
323
      |> case do
324
        {:ok, beams} -> {:ok, Enum.sort_by(beams, & &1.module)}
325
        error -> error
326
      end
327
    end
328
  end
329
330
  defp run_ok(executable, args, cwd, env, output) do
331
    case run_command(executable, args, cwd, env, output) do
332
      {:ok, _excerpt} -> :ok
333
      {:error, reason, _excerpt} -> {:error, reason}
334
    end
335
  end
336
337
  defp run_command(executable, args, cwd, env, output) do
338
    case System.find_executable(executable) do
339
      nil ->
340
        {:error, {:executable_missing, executable}, ""}
341
342
      path ->
343
        {:ok, io} = File.open(output, [:append, :binary])
344
345
        try do
346
          port =
347
            Port.open(
348
              {:spawn_executable, String.to_charlist(path)},
349
              [
350
                :binary,
351
                :exit_status,
352
                :stderr_to_stdout,
353
                args: Enum.map(args, &String.to_charlist/1),
354
                cd: String.to_charlist(cwd),
355
                env: Enum.map(env, fn {key, value} -> {to_charlist(key), to_charlist(value)} end)
356
              ]
357
            )
358
359
          collect_port(port, io, "")
360
        after
361
          File.close(io)
362
        end
363
    end
364
  rescue
365
    error -> {:error, {:command_crashed, executable, Exception.message(error)}, ""}
366
  end
367
368
  defp collect_port(port, io, excerpt) do
369
    receive do
370
      {^port, {:data, data}} ->
371
        :ok = IO.binwrite(io, data)
372
        collect_port(port, io, append_excerpt(excerpt, data))
373
374
      {^port, {:exit_status, 0}} ->
375
        {:ok, excerpt}
376
377
      {^port, {:exit_status, status}} ->
378
        {:error, {:command_failed, status}, excerpt}
379
    after
380
      600_000 ->
381
        Port.close(port)
382
        {:error, :command_timeout, excerpt}
383
    end
384
  end
385
386
  defp append_excerpt(excerpt, data) do
387
    remaining = @max_command_excerpt - byte_size(excerpt)
388
389
    if remaining > 0,
390
      do: excerpt <> binary_part(data, 0, min(remaining, byte_size(data))),
391
      else: excerpt
392
  end
393
394
  defp command_env(opts) do
395
    askpass = Keyword.get(opts, :askpass, System.get_env("OPENAGENTS_FORGE_GIT_ASKPASS"))
396
    base = [{"GIT_TERMINAL_PROMPT", "0"}, {"GIT_CONFIG_NOSYSTEM", "1"}]
397
398
    case askpass do
399
      nil ->
400
        {:ok, base}
401
402
      path when is_binary(path) ->
403
        with true <- Path.type(path) == :absolute or {:error, :askpass_not_absolute},
404
             {:ok, %{type: :regular, mode: mode}} <- File.stat(path),
405
             true <- Bitwise.band(mode, 0o111) != 0 or {:error, :askpass_not_executable} do
406
          {:ok, [{"GIT_ASKPASS", path} | base]}
407
        else
408
          {:error, _reason} = error -> error
409
          _other -> {:error, :invalid_askpass}
410
        end
411
    end
412
  end
413
414
  defp store_artifact(artifacts, artifact) do
415
    path = Path.join([artifacts, "artifacts", artifact.digest <> ".tar"])
416
417
    case BuildProtocol.atomic_write(path, artifact.bytes, mode: 0o444) do
418
      :ok ->
419
        :ok
420
421
      {:error, :destination_exists} ->
422
        with {:ok, existing} <- File.read(path),
423
             true <-
424
               BuildArtifact.digest(existing) == artifact.digest or
425
                 {:error, :artifact_digest_collision} do
426
          :ok
427
        end
428
429
      {:error, reason} ->
430
        {:error, {:artifact_write_failed, reason}}
431
    end
432
  end
433
434
  defp finalize_output!(artifacts, build_id, tmp) do
435
    ref = "output/#{build_id}.log"
436
    final = Path.join(artifacts, ref)
437
    File.mkdir_p!(Path.dirname(final))
438
    File.rm(final)
439
    File.rename!(tmp, final)
440
    File.chmod!(final, 0o600)
441
    {ref, digest_file!(final)}
442
  end
443
444
  defp finalize_output_after_crash(artifacts, build_id, tmp, crash_output) do
445
    File.mkdir_p!(Path.dirname(tmp))
446
    File.write!(tmp, crash_output, [:append, :binary])
447
    finalize_output!(artifacts, build_id, tmp)
448
  rescue
449
    _error -> {nil, nil}
450
  end
451
452
  defp digest_file!(path) do
453
    context =
454
      path
455
      |> File.stream!([], 64 * 1_024)
456
      |> Enum.reduce(:crypto.hash_init(:sha256), &:crypto.hash_update(&2, &1))
457
458
    context |> :crypto.hash_final() |> Base.encode16(case: :lower)
459
  end
460
461
  defp output_excerpt(_artifacts, nil), do: ""
462
463
  defp output_excerpt(artifacts, ref) do
464
    path = Path.join(artifacts, ref)
465
466
    with {:ok, io} <- File.open(path, [:read, :binary]) do
467
      try do
468
        case IO.binread(io, @max_command_excerpt) do
469
          :eof -> ""
470
          {:error, _reason} -> ""
471
          excerpt -> bound_redacted_excerpt(excerpt)
472
        end
473
      after
474
        File.close(io)
475
      end
476
    else
477
      {:error, _reason} -> ""
478
    end
479
  end
480
481
  defp bound_redacted_excerpt(excerpt) do
482
    redacted = OpenAgents.LogSafety.redact(excerpt)
483
484
    if byte_size(redacted) <= @max_command_excerpt,
485
      do: redacted,
486
      else: binary_part(redacted, 0, @max_command_excerpt)
487
  end
488
489
  defp write_response(queue, response) do
490
    with {:ok, encoded} <- BuildProtocol.encode_response(response) do
491
      BuildProtocol.atomic_write(
492
        Path.join([queue, "responses", response["build_id"] <> ".json"]),
493
        encoded,
494
        mode: 0o640
495
      )
496
    end
497
  end
498
499
  defp claim(request_path, queue) do
500
    running_path = Path.join([queue, "running", Path.basename(request_path)])
501
    File.mkdir_p!(Path.dirname(running_path))
502
503
    case File.rename(request_path, running_path) do
504
      :ok -> {:ok, running_path}
505
      {:error, :enoent} -> :lost_race
506
      {:error, :eexist} -> :lost_race
507
      {:error, reason} -> raise "build request claim failed: #{inspect(reason)}"
508
    end
509
  end
510
511
  defp expire_abandoned(queue) do
512
    queue
513
    |> Path.join("running/*.json")
514
    |> Path.wildcard()
515
    |> Enum.each(fn path ->
516
      with {:ok, bytes} <- File.read(path),
517
           {:ok, request} <- BuildProtocol.decode_request(bytes),
518
           {:error, :request_expired} <- ensure_not_expired(request) do
519
        response =
520
          BuildProtocol.error_response(
521
            request["build_id"],
522
            "expired",
523
            "abandoned_build_expired",
524
            "builder did not complete before the request expiry"
525
          )
526
527
        write_response(queue, response)
528
        File.rm(path)
529
      else
530
        _active_or_malformed -> :ok
531
      end
532
    end)
533
  end
534
535
  defp ensure_not_expired(request) do
536
    {:ok, expiry, 0} = DateTime.from_iso8601(request["expires_at"])
537
538
    if DateTime.compare(DateTime.utc_now(), expiry) == :lt,
539
      do: :ok,
540
      else: {:error, :request_expired}
541
  end
542
543
  defp request_files(queue) do
544
    queue |> Path.join("requests/*.json") |> Path.wildcard() |> Enum.sort()
545
  end
546
547
  defp expire_outputs(artifacts, opts) do
548
    retention_ms = output_retention_ms(opts)
549
    now_ms = System.system_time(:millisecond)
550
551
    artifacts
552
    |> Path.join("output/*.log")
553
    |> Path.wildcard()
554
    |> Enum.each(fn path ->
555
      case File.stat(path, time: :posix) do
556
        {:ok, %{mtime: mtime}} when now_ms - mtime * 1000 > retention_ms -> File.rm(path)
557
        _active_or_unreadable -> :ok
558
      end
559
    end)
560
  end
561
562
  defp output_retention_ms(opts) do
563
    Keyword.get_lazy(opts, :output_retention_ms, fn ->
564
      case Integer.parse(System.get_env("OPENAGENTS_FORGE_BUILD_OUTPUT_RETENTION_MS") || "") do
565
        {value, ""} when value >= 86_400_000 -> value
566
        _invalid_or_missing -> @default_output_retention_ms
567
      end
568
    end)
569
  end
570
571
  defp build_id_from_path(path), do: path |> Path.basename(".json")
572
573
  defp error_code(reason) do
574
    reason
575
    |> case do
576
      atom when is_atom(atom) -> Atom.to_string(atom)
577
      {atom, _rest} when is_atom(atom) -> Atom.to_string(atom)
578
      _other -> "build_failed"
579
    end
580
    |> String.replace(~r/[^a-z0-9_]/, "_")
581
    |> String.slice(0, 128)
582
  end
583
584
  defp ensure_builder_paths!(queue, artifacts, builds) do
585
    Enum.each([queue, artifacts, builds], fn path ->
586
      unless Path.type(path) == :absolute do
587
        raise ArgumentError, "builder paths must be absolute"
588
      end
589
    end)
590
591
    for path <- [
592
          Path.join(queue, "requests"),
593
          Path.join(queue, "running"),
594
          Path.join(queue, "responses"),
595
          Path.join(artifacts, "artifacts"),
596
          Path.join(artifacts, "output"),
597
          Path.join(builds, "jobs")
598
        ],
599
        do: File.mkdir_p!(path)
600
  end
601
602
  defp required_env!(name) do
603
    case System.get_env(name) do
604
      value when is_binary(value) and value != "" -> value
605
      _missing -> raise ArgumentError, "#{name} is required"
606
    end
607
  end
608
end
lib/openagents/forge/builder.ex modified +292 -112

@@ -1,39 +1,38 @@

1 1
defmodule OpenAgents.Forge.Builder do
2 2
  @moduledoc """
3
  Serial build worker for the forge deploy lane. Subscribes to
4
  `forge:target` promotions; on each one it advances the target to
5
  `building`, runs the configured `OpenAgents.Forge.BuildExecutor`, writes the
6
  changed-beam artifact tar under `<data_dir>/beams/<sha>.tar`, records a
7
  `OpenAgents.Forge.BuildReceipt`, advances the target to `built` (or
8
  `failed` with bounded output), and broadcasts build-ready on
9
  `forge:builds` for the hot-load lane (P4).
10
11
  One build at a time by construction: the work happens inline in
12
  `handle_info/2`. A bad build never crashes the worker — failures land
13
  on the target as status `failed` with a bounded message.
3
  Serial coordinator for durable, isolated forge build attempts.
4
5
  The coordinator creates a UUID receipt before queueing work, requires a
6
  verified digest-addressed artifact in durable storage before marking the
7
  target built, and periodically recovers stale `building` targets. Recovery
8
  expires the abandoned build ID and creates a different ID, so late sidecar
9
  responses cannot satisfy the retry.
14 10
  """
15 11
16 12
  use GenServer
17 13
14
  import Ecto.Query
15
18 16
  require Logger
19 17
18
  alias OpenAgents.Forge.BuildArtifact
20 19
  alias OpenAgents.Forge.BuildExecutor
20
  alias OpenAgents.Forge.BuildProtocol
21 21
  alias OpenAgents.Forge.BuildReceipt
22 22
  alias OpenAgents.Forge.Repos
23
  alias OpenAgents.Forge.Target
23 24
  alias OpenAgents.Forge.Targets
24 25
  alias OpenAgents.Repo
25 26
27
  @recovery_interval_ms 60_000
28
  @abandoned_after_ms 360_000
29
26 30
  def start_link(opts) do
27 31
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
28 32
  end
29 33
30 34
  @impl true
31
  def init(_opts) do
32
    # Subscribe from handle_continue with retry: the deploy lane degrades
33
    # honestly if PubSub is not up yet — it never takes the application down
34
    # (2026-08-19 fleet boot-order incident).
35
    {:ok, %{}, {:continue, :subscribe}}
36
  end
35
  def init(_opts), do: {:ok, %{}, {:continue, :subscribe}}
37 36
38 37
  @impl true
39 38
  def handle_continue(:subscribe, state) do

@@ -43,35 +42,30 @@ defmodule OpenAgents.Forge.Builder do

43 42
      _error -> Process.send_after(self(), :resubscribe, 1_000)
44 43
    end
45 44
45
    send(self(), :recover_abandoned)
46 46
    {:noreply, state}
47 47
  end
48 48
49 49
  @impl true
50 50
  def handle_info(:resubscribe, state), do: handle_continue(:subscribe, state)
51 51
52
  def handle_info({:forge_target, %{repo: repo, sha: sha, target_id: target_id}}, state) do
53
    run_build(repo, sha, target_id)
52
  def handle_info(:recover_abandoned, state) do
53
    recover_abandoned()
54
    Process.send_after(self(), :recover_abandoned, @recovery_interval_ms)
54 55
    {:noreply, state}
55 56
  end
56 57
57
  def handle_info(_message, state), do: {:noreply, state}
58
59
  # ── one build, start to finish, crash-safe ──────────────────────────────
58
  def handle_info({:forge_target, %{target_id: target_id}}, state) do
59
    run_promoted(target_id)
60
    {:noreply, state}
61
  end
60 62
61
  defp run_build(repo, sha, target_id) do
62
    # Every node's Builder hears the promotion; only the one that wins the
63
    # promoted->building transition builds. The rest skip silently. Nodes
64
    # with a cold build workspace hold back briefly so a warm node wins the
65
    # transition when one exists (preference for efficiency; correctness
66
    # never depends on which node builds — the Continuity shape).
67
    unless warm_workspace?(), do: Process.sleep(5_000)
63
  def handle_info(_message, state), do: {:noreply, state}
68 64
65
  defp run_promoted(target_id) do
69 66
    case Targets.advance(target_id, "building") do
70
      {:ok, _target} ->
71
        case executor().build(repo, sha, []) do
72
          {:ok, result} -> finish(repo, sha, target_id, result)
73
          {:error, output} -> fail(target_id, output)
74
        end
67
      {:ok, target} ->
68
        run_attempt(target)
75 69
76 70
      {:error, reason} ->
77 71
        Logger.debug("forge_build_not_owner code=#{OpenAgents.OperationalLog.code(reason)}")

@@ -79,33 +73,187 @@ defmodule OpenAgents.Forge.Builder do

79 73
    end
80 74
  rescue
81 75
    error ->
82
      fail(target_id, "builder_crashed code=" <> OpenAgents.OperationalLog.code(error))
76
      fail_target(
77
        target_id,
78
        "builder_crashed",
79
        "code=" <> OpenAgents.OperationalLog.code(error)
80
      )
81
  catch
82
    _kind, reason ->
83
      fail_target(
84
        target_id,
85
        "builder_crashed",
86
        "code=" <> OpenAgents.OperationalLog.code(reason)
87
      )
83 88
  end
84 89
85
  defp finish(repo, sha, target_id, result) do
86
    modules = Enum.map(result.beams, & &1.module)
87
    {artifact_abs, artifact_rel} = write_artifact!(sha, result.beams)
88
    upload_artifact(repo, sha, artifact_abs)
89
    record_receipt(repo, sha, target_id, modules, artifact_rel, result)
90
  defp run_attempt(%Target{} = target) do
91
    build_id = Ecto.UUID.generate()
92
    baseline_manifest = live_manifest(target.repo)
93
94
    receipt =
95
      %BuildReceipt{id: build_id}
96
      |> BuildReceipt.start_changeset(%{
97
        repo: target.repo,
98
        sha: target.sha,
99
        target_id: target.id,
100
        baseline_manifest: baseline_manifest
101
      })
102
      |> Repo.insert()
103
104
    case receipt do
105
      {:ok, receipt} ->
106
        opts = [
107
          build_id: build_id,
108
          target_id: target.id,
109
          baseline_manifest: baseline_manifest
110
        ]
111
112
        case executor().build(target.repo, target.sha, opts) do
113
          {:ok, result} -> finish(target, receipt, result)
114
          {:error, error} -> fail_attempt(target, receipt, error)
115
        end
90 116
91
    case Targets.advance(target_id, "built", %{"artifact" => artifact_rel, "modules" => modules}) do
92
      {:ok, _target} ->
93
        Phoenix.PubSub.broadcast(
94
          OpenAgents.PubSub,
95
          "forge:builds",
96
          {:forge_build_ready,
97
           %{repo: repo, sha: sha, target_id: target_id, artifact: artifact_abs, modules: modules}}
117
      {:error, changeset} ->
118
        Logger.debug(
119
          "forge_build_attempt_not_owner code=#{OpenAgents.OperationalLog.code(changeset)}"
98 120
        )
121
    end
122
  rescue
123
    error ->
124
      if receipt = running_receipt(target.id) do
125
        fail_attempt(target, receipt, %{
126
          code: "builder_crashed",
127
          output: "code=" <> OpenAgents.OperationalLog.code(error)
128
        })
129
      else
130
        fail_target(
131
          target.id,
132
          "builder_crashed",
133
          "code=" <> OpenAgents.OperationalLog.code(error)
134
        )
135
      end
136
  catch
137
    _kind, reason ->
138
      message = "code=" <> OpenAgents.OperationalLog.code(reason)
139
140
      if receipt = running_receipt(target.id) do
141
        fail_attempt(target, receipt, %{code: "builder_crashed", output: message})
142
      else
143
        fail_target(target.id, "builder_crashed", message)
144
      end
145
  end
99 146
147
  defp finish(target, receipt, result) do
148
    with {:ok, verified} <-
149
           BuildArtifact.verify(result.artifact_bytes,
150
             digest: result.artifact_digest,
151
             repo: target.repo,
152
             source_sha: target.sha,
153
             build_id: receipt.id
154
           ),
155
         true <- verified.manifest == result.manifest or {:error, :executor_manifest_mismatch},
156
         {:ok, artifact_abs, artifact_rel} <- store_local(result.artifact_bytes, verified.digest),
157
         {:ok, _key} <-
158
           OpenAgents.Forge.WAL.put_artifact(
159
             target.repo,
160
             verified.digest,
161
             result.artifact_bytes
162
           ),
163
         {:ok, _receipt} <- complete_receipt(receipt, artifact_rel, verified, result),
164
         {:ok, _target} <-
165
           Targets.advance(target.id, "built", %{
166
             "artifact" => artifact_rel,
167
             "artifact_digest" => verified.digest,
168
             "build_id" => receipt.id,
169
             "classification" => verified.manifest["classification"],
170
             "manifest" => verified.manifest,
171
             "modules" => verified.modules
172
           }) do
173
      Phoenix.PubSub.broadcast(
174
        OpenAgents.PubSub,
175
        "forge:builds",
176
        {:forge_build_ready,
177
         %{
178
           repo: target.repo,
179
           sha: target.sha,
180
           target_id: target.id,
181
           build_id: receipt.id,
182
           artifact: artifact_abs,
183
           artifact_digest: verified.digest,
184
           manifest: verified.manifest,
185
           modules: verified.modules
186
         }}
187
      )
188
    else
100 189
      {:error, reason} ->
101
        Logger.warning(
102
          "forge_build_advance_failed status=built code=#{OpenAgents.OperationalLog.code(reason)}"
103
        )
190
        fail_attempt(target, receipt, %{
191
          code: "build_finalize_failed",
192
          output: inspect(reason)
193
        })
194
195
      false ->
196
        fail_attempt(target, receipt, %{
197
          code: "build_finalize_failed",
198
          output: "executor manifest mismatch"
199
        })
200
    end
201
  end
202
203
  defp complete_receipt(receipt, artifact_rel, verified, result) do
204
    Repo.transaction(fn ->
205
      current = Repo.get!(BuildReceipt, receipt.id, lock: "FOR UPDATE")
206
207
      if current.status != "running" do
208
        Repo.rollback(:attempt_not_running)
209
      end
210
211
      current
212
      |> BuildReceipt.complete_changeset(%{
213
        manifest: verified.manifest,
214
        modules: verified.modules,
215
        warnings: BuildExecutor.bound_output(result.warnings || ""),
216
        tests: result.tests,
217
        duration_ms: result.duration_ms,
218
        artifact: artifact_rel,
219
        artifact_digest: verified.digest,
220
        output_digest: result.output_digest,
221
        output_ref: result.output_ref
222
      })
223
      |> Repo.update!()
224
    end)
225
    |> case do
226
      {:ok, receipt} -> {:ok, receipt}
227
      {:error, reason} -> {:error, reason}
104 228
    end
105 229
  end
106 230
107
  defp fail(target_id, output) do
108
    case Targets.advance(target_id, "failed", %{"error" => BuildExecutor.bound_output(output)}) do
231
  defp fail_attempt(target, receipt, error) do
232
    error = normalize_error(error)
233
234
    Repo.transaction(fn ->
235
      current = Repo.get(BuildReceipt, receipt.id, lock: "FOR UPDATE")
236
237
      if current && current.status == "running" do
238
        current
239
        |> BuildReceipt.terminal_changeset("failed", %{
240
          warnings: BuildExecutor.bound_output(error.output),
241
          duration_ms: Map.get(error, :duration_ms, 0),
242
          output_digest: Map.get(error, :output_digest),
243
          output_ref: Map.get(error, :output_ref),
244
          error_code: error.code
245
        })
246
        |> Repo.update!()
247
      end
248
    end)
249
250
    fail_target(target.id, error.code, error.output)
251
  end
252
253
  defp fail_target(target_id, code, output) do
254
    message = BuildExecutor.bound_output("#{code}: #{output}")
255
256
    case Targets.advance(target_id, "failed", %{"error" => message, "error_code" => code}) do
109 257
      {:ok, _target} ->
110 258
        :ok
111 259

@@ -116,71 +264,112 @@ defmodule OpenAgents.Forge.Builder do

116 264
    end
117 265
  end
118 266
119
  # ── artifact + receipt ──────────────────────────────────────────────────
120
121
  defp write_artifact!(sha, beams) do
122
    artifact_rel = Path.join("beams", sha <> ".tar")
123
    artifact_abs = Path.join(Repos.data_dir(), artifact_rel)
124
125
    # The sidecar executor already wrote this artifact (as root, in prod);
126
    # rewriting it from the unprivileged app both fails on permissions and
127
    # is redundant — the existing tar IS the artifact of record. Only
128
    # non-sidecar executors (tests, future in-process builds) write here.
129
    unless File.exists?(artifact_abs) do
130
      File.mkdir_p!(Path.dirname(artifact_abs))
267
  defp normalize_error(%{code: code, output: output} = error) do
268
    %{
269
      code: safe_error_code(code),
270
      output: to_string(output),
271
      duration_ms: Map.get(error, :duration_ms, 0),
272
      output_digest: Map.get(error, :output_digest),
273
      output_ref: Map.get(error, :output_ref)
274
    }
275
  end
131 276
132
      entries =
133
        Enum.map(beams, fn %{module: module, binary: binary} ->
134
          {String.to_charlist(module <> ".beam"), binary}
135
        end)
277
  defp normalize_error(output) do
278
    %{code: "build_failed", output: to_string(output), duration_ms: 0}
279
  end
136 280
137
      :ok = :erl_tar.create(String.to_charlist(artifact_abs), entries)
281
  defp safe_error_code(code) do
282
    code
283
    |> to_string()
284
    |> String.replace(~r/[^a-z0-9_]/, "_")
285
    |> String.slice(0, 128)
286
    |> case do
287
      "" -> "build_failed"
288
      value -> value
138 289
    end
139
140
    {artifact_abs, artifact_rel}
141 290
  end
142 291
143
  # Best-effort artifact upload to the WAL store (P6, #123): a replaced
144
  # node with an empty partition boot-converges by fetching this blob. The
145
  # local tar remains the deploy path; the upload never blocks a build.
146
  defp upload_artifact(repo, sha, artifact_abs) do
147
    case File.read(artifact_abs) do
148
      {:ok, payload} ->
149
        case OpenAgents.Forge.WAL.put_artifact(repo, sha, payload) do
150
          {:ok, _key} ->
151
            :ok
292
  defp store_local(bytes, digest) do
293
    artifact_rel = Path.join("beams", digest <> ".tar")
294
    artifact_abs = Path.join(Repos.data_dir(), artifact_rel)
295
296
    case BuildProtocol.atomic_write(artifact_abs, bytes) do
297
      :ok ->
298
        {:ok, artifact_abs, artifact_rel}
152 299
153
          {:error, reason} ->
154
            Logger.warning(
155
              "forge_artifact_upload_failed code=#{OpenAgents.OperationalLog.code(reason)}"
156
            )
300
      {:error, :destination_exists} ->
301
        with {:ok, existing} <- File.read(artifact_abs),
302
             true <- BuildArtifact.digest(existing) == digest or {:error, :digest_collision} do
303
          {:ok, artifact_abs, artifact_rel}
157 304
        end
158 305
159 306
      {:error, reason} ->
160
        Logger.warning(
161
          "forge_artifact_read_failed code=#{OpenAgents.OperationalLog.code(reason)}"
162
        )
307
        {:error, {:artifact_cache_write_failed, reason}}
308
    end
309
  end
310
311
  defp live_manifest(repo) do
312
    case Targets.live(repo) do
313
      nil ->
314
        nil
315
316
      %Target{id: target_id} ->
317
        BuildReceipt
318
        |> where([b], b.target_id == ^target_id and b.status == "complete")
319
        |> where([b], not is_nil(b.manifest))
320
        |> order_by([b], desc: b.inserted_at)
321
        |> select([b], b.manifest)
322
        |> limit(1)
323
        |> Repo.one()
163 324
    end
325
  end
326
327
  defp recover_abandoned do
328
    Target
329
    |> where([t], t.status == "building")
330
    |> Repo.all()
331
    |> Enum.each(&recover_target/1)
164 332
  rescue
165 333
    error ->
166
      Logger.warning(
167
        "forge_artifact_upload_crashed code=#{OpenAgents.OperationalLog.code(error)}"
168
      )
334
      Logger.warning("forge_build_recovery_failed code=#{OpenAgents.OperationalLog.code(error)}")
335
  end
336
337
  defp recover_target(target) do
338
    :global.trans({{:forge_build_recovery, target.id}, self()}, fn ->
339
      case running_receipt(target.id) do
340
        nil ->
341
          run_attempt(target)
342
343
        receipt ->
344
          if abandoned?(receipt) do
345
            receipt
346
            |> BuildReceipt.terminal_changeset("expired", %{
347
              error_code: "builder_restart_expired",
348
              warnings: "build coordinator disappeared before completion",
349
              duration_ms: 0
350
            })
351
            |> Repo.update!()
352
353
            run_attempt(target)
354
          end
355
      end
356
    end)
169 357
  end
170 358
171
  defp record_receipt(repo, sha, target_id, modules, artifact_rel, result) do
172
    %BuildReceipt{}
173
    |> BuildReceipt.changeset(%{
174
      repo: repo,
175
      sha: sha,
176
      target_id: target_id,
177
      modules: modules,
178
      warnings: result.warnings,
179
      tests: result.tests,
180
      duration_ms: result.duration_ms,
181
      artifact: artifact_rel
182
    })
183
    |> Repo.insert!(on_conflict: :nothing, conflict_target: [:repo, :sha, :target_id])
359
  defp running_receipt(target_id) do
360
    BuildReceipt
361
    |> where([b], b.target_id == ^target_id and b.status == "running")
362
    |> order_by([b], desc: b.inserted_at)
363
    |> limit(1)
364
    |> Repo.one()
365
  end
366
367
  defp abandoned?(receipt) do
368
    threshold =
369
      Application.get_env(:openagents, :forge_build_abandoned_after_ms, @abandoned_after_ms)
370
371
    DateTime.diff(DateTime.utc_now(), receipt.updated_at || receipt.inserted_at, :millisecond) >=
372
      threshold
184 373
  end
185 374
186 375
  defp executor do

@@ -190,13 +379,4 @@ defmodule OpenAgents.Forge.Builder do

190 379
      OpenAgents.Forge.BuildExecutor.Sidecar
191 380
    )
192 381
  end
193
194
  # Warm = the sidecar's incremental-build manifest exists on this node.
195
  # Non-sidecar executors (tests) are always "warm" — no artificial delay.
196
  defp warm_workspace?() do
197
    case executor() do
198
      OpenAgents.Forge.BuildExecutor.Sidecar -> OpenAgents.Forge.BuildExecutor.Sidecar.warm?()
199
      _other -> true
200
    end
201
  end
202 382
end
lib/openagents/forge/hot_loader.ex modified +70 -55

@@ -19,6 +19,7 @@ defmodule OpenAgents.Forge.HotLoader do

19 19
20 20
  require Logger
21 21
22
  alias OpenAgents.Forge.BuildArtifact
22 23
  alias OpenAgents.Forge.DeployReceipt
23 24
  alias OpenAgents.Forge.PushReceipt
24 25
  alias OpenAgents.Forge.Targets

@@ -96,31 +97,22 @@ defmodule OpenAgents.Forge.HotLoader do

96 97
  # ── deploy lane ──────────────────────────────────────────────────────────
97 98
98 99
  defp handle_build(%{repo: repo, sha: sha, target_id: target_id, modules: modules} = build) do
99
    allowlist = Application.get_env(:openagents, :forge_hot_load_allowlist, @default_allowlist)
100
    offending = Enum.reject(modules, &allowlisted?(&1, allowlist))
101
102
    cond do
103
      not File.exists?(build.artifact) ->
104
        # The artifact tar is node-local; the builder node's hot-loader is
105
        # the one that can actually deploy (it ships beams to the rest of
106
        # the fleet as erpc arguments). Every other node skips.
107
        :ok
108
109
      offending == [] ->
110
        deploy(build)
111
112
      true ->
113
        # Never a partial load: one off-allowlist module refuses the whole
114
        # artifact, honestly, with the offender names. (Only the winner of
115
        # the transition records it; racing nodes get invalid_transition.)
116
        case advance(target_id, "needs_rolling_replace", %{"modules" => offending}) do
117
          :ok ->
118
            insert_receipt(repo, sha, target_id, modules, [], "needs_rolling_replace", nil, nil)
119
            broadcast_deploy(repo, sha, "needs_rolling_replace")
120
121
          :error ->
122
            :ok
123
        end
100
    if File.exists?(build.artifact) do
101
      with {:ok, verified} <-
102
             BuildArtifact.verify_file(build.artifact,
103
               digest: Map.get(build, :artifact_digest),
104
               repo: repo,
105
               source_sha: sha,
106
               build_id: Map.get(build, :build_id)
107
             ),
108
           true <- verified.modules == modules or {:error, :declared_modules_mismatch},
109
           true <-
110
             is_nil(Map.get(build, :manifest)) or Map.get(build, :manifest) == verified.manifest or
111
               {:error, :declared_manifest_mismatch} do
112
        route_verified(build, verified)
113
      else
114
        {:error, reason} -> fail_verified_build(build, reason)
115
      end
124 116
    end
125 117
  rescue
126 118
    error ->

@@ -133,31 +125,60 @@ defmodule OpenAgents.Forge.HotLoader do

133 125
    :refused -> :ok
134 126
  end
135 127
136
  defp deploy(%{repo: repo, sha: sha, target_id: target_id, artifact: artifact, modules: modules}) do
137
    case advance(target_id, "deploying") do
138
      :ok -> :ok
139
      :error -> throw(:refused)
128
  defp route_verified(build, verified) do
129
    allowlist = Application.get_env(:openagents, :forge_hot_load_allowlist, @default_allowlist)
130
    offending = Enum.reject(verified.modules, &allowlisted?(&1, allowlist))
131
132
    cond do
133
      verified.manifest["classification"] != "direct_candidate" ->
134
        route_rolling(build, verified.manifest["structural_reasons"])
135
136
      offending != [] ->
137
        route_rolling(build, Enum.map(offending, &"off_allowlist:#{&1}"))
138
139
      true ->
140
        deploy(build, verified)
140 141
    end
142
  end
141 143
142
    beams = extract!(artifact)
144
  defp route_rolling(%{repo: repo, sha: sha, target_id: target_id, modules: modules}, reasons) do
145
    case advance(target_id, "needs_rolling_replace", %{
146
           "modules" => modules,
147
           "reasons" => reasons
148
         }) do
149
      :ok ->
150
        insert_receipt(repo, sha, target_id, modules, [], "needs_rolling_replace", nil, nil)
151
        broadcast_deploy(repo, sha, "needs_rolling_replace")
143 152
144
    # Defense in depth: the declared module list was allowlist-checked, but
145
    # the artifact's actual entries are what get loaded — re-check them so a
146
    # tar that disagrees with its declaration can never smuggle a module.
147
    allowlist = Application.get_env(:openagents, :forge_hot_load_allowlist, @default_allowlist)
153
      :error ->
154
        :ok
155
    end
156
  end
148 157
149
    extracted_offenders =
150
      beams
151
      |> Enum.map(fn {mod, _binary} -> to_string(mod) end)
152
      |> Enum.reject(&allowlisted?(&1, allowlist))
158
  defp fail_verified_build(
159
         %{repo: repo, sha: sha, target_id: target_id, modules: modules},
160
         reason
161
       ) do
162
    message = "artifact_verification_failed code=" <> OpenAgents.OperationalLog.code(reason)
163
    Logger.error(message)
164
    advance(target_id, "failed", %{"error" => message})
165
    insert_receipt(repo, sha, target_id, modules, [], "failed", nil, nil)
166
    broadcast_deploy(repo, sha, "failed")
167
  end
153 168
154
    if extracted_offenders != [] do
155
      advance(target_id, "needs_rolling_replace", %{"modules" => extracted_offenders})
156
      insert_receipt(repo, sha, target_id, modules, [], "needs_rolling_replace", nil, nil)
157
      broadcast_deploy(repo, sha, "needs_rolling_replace")
158
      throw(:refused)
169
  defp deploy(%{repo: repo, sha: sha, target_id: target_id, modules: modules}, verified) do
170
    case advance(target_id, "deploying") do
171
      :ok -> :ok
172
      :error -> throw(:refused)
159 173
    end
160 174
175
    # Atom creation happens only here, after the full tar and manifest have
176
    # passed every bounded identity and classification check.
177
    beams =
178
      Enum.map(verified.beams, fn %{module: module, binary: binary} ->
179
        {BuildArtifact.module_atom(module), binary}
180
      end)
181
161 182
    case canary_load(beams) do
162 183
      :ok ->
163 184
        nodes = fleet_load(beams)

@@ -183,22 +204,16 @@ defmodule OpenAgents.Forge.HotLoader do

183 204
    )
184 205
  end
185 206
186
  @doc "Extract a beam artifact tar into {module, binary} pairs (also used by BootConverge)."
207
  @doc "Verify an artifact completely, then return `{module_atom, binary}` pairs."
187 208
  def extract!(artifact) do
188
    case :erl_tar.extract(String.to_charlist(artifact), [:memory]) do
189
      {:ok, entries} ->
190
        Enum.map(entries, fn {name, binary} ->
191
          mod =
192
            name
193
            |> List.to_string()
194
            |> Path.basename(".beam")
195
            |> String.to_atom()
196
197
          {mod, binary}
209
    case BuildArtifact.verify_file(artifact) do
210
      {:ok, verified} ->
211
        Enum.map(verified.beams, fn %{module: module, binary: binary} ->
212
          {BuildArtifact.module_atom(module), binary}
198 213
        end)
199 214
200 215
      {:error, reason} ->
201
        raise "artifact extract failed for #{artifact}: #{inspect(reason)}"
216
        raise "artifact verification failed for #{artifact}: #{inspect(reason)}"
202 217
    end
203 218
  end
204 219
lib/openagents/forge/janitor.ex modified +1 -1

@@ -8,7 +8,7 @@ defmodule OpenAgents.Forge.Janitor do

8 8
    removes it) or unknown, and whose mtime is older than the retention
9 9
    window, is pruned. Covers workers that died between mutation and
10 10
    cleanup.
11
  - **Stale beam artifacts**: `beams/<sha>.tar` files older than the window
11
  - **Stale beam artifacts**: `beams/<artifact-sha256>.tar` files older than the window
12 12
    that are NOT the current live target's artifact are pruned — the WAL and
13 13
    receipts remain the durable record; the tars are cache.
14 14
lib/openagents/forge/targets.ex modified +10 -1

@@ -77,6 +77,15 @@ defmodule OpenAgents.Forge.Targets do

77 77
    |> Repo.one()
78 78
  end
79 79
80
  @doc "The newest immutable live target for `repo`, or nil."
81
  def live(repo) do
82
    Target
83
    |> where([t], t.repo == ^repo and t.status == "live")
84
    |> order_by([t], desc: t.inserted_at)
85
    |> limit(1)
86
    |> Repo.one()
87
  end
88
80 89
  @doc "Recent targets for a repo, newest first, bounded."
81 90
  def recent(repo, limit \\ 10) do
82 91
    Target

@@ -146,7 +155,7 @@ defmodule OpenAgents.Forge.Targets do

146 155
  end
147 156
148 157
  defp validate_sha_format(sha) do
149
    if Regex.match?(~r/^[0-9a-f]{7,40}$/, sha), do: :ok, else: {:error, :invalid_sha}
158
    if Regex.match?(~r/^[0-9a-f]{40}$/, sha), do: :ok, else: {:error, :invalid_sha}
150 159
  end
151 160
152 161
  # The promotable set is exactly what the WAL-backed repo contains.
lib/openagents/forge/wal.ex modified +15 -8

@@ -50,7 +50,7 @@ defmodule OpenAgents.Forge.WAL do

50 50
51 51
  @repo_pattern ~r/^[a-z0-9](?:[a-z0-9_-]|\.(?=[a-z0-9]))*$/
52 52
  @entry_key_pattern ~r/^entries\/[0-9]{8}-[0-9a-f]{12}$/
53
  @artifact_key_pattern ~r/^artifacts\/[0-9a-f]{7,40}\.tar$/
53
  @artifact_key_pattern ~r/^artifacts\/[0-9a-f]{64}\.tar$/
54 54
55 55
  ## Dispatcher
56 56

@@ -109,19 +109,20 @@ defmodule OpenAgents.Forge.WAL do

109 109
  same content is re-buildable from the pushed commit.
110 110
  """
111 111
  @spec put_artifact(repo, String.t(), binary()) :: {:ok, String.t()} | {:error, term}
112
  def put_artifact(repo, sha, payload) when is_binary(sha) and is_binary(payload) do
113
    key = artifact_key(sha)
112
  def put_artifact(repo, digest, payload) when is_binary(digest) and is_binary(payload) do
113
    key = artifact_key(digest)
114 114
115 115
    with :ok <- validate_repo(repo),
116
         :ok <- validate_artifact_key(key) do
116
         :ok <- validate_artifact_key(key),
117
         true <- artifact_digest(payload) == digest or {:error, :artifact_digest_mismatch} do
117 118
      adapter().put_object(repo, key, payload)
118 119
    end
119 120
  end
120 121
121
  @doc "Fetch an artifact blob by sha (see `put_artifact/3`)."
122
  @doc "Fetch an artifact blob by SHA-256 digest (see `put_artifact/3`)."
122 123
  @spec get_artifact(repo, String.t()) :: {:ok, binary()} | {:error, term}
123
  def get_artifact(repo, sha) when is_binary(sha) do
124
    key = artifact_key(sha)
124
  def get_artifact(repo, digest) when is_binary(digest) do
125
    key = artifact_key(digest)
125 126
126 127
    with :ok <- validate_repo(repo),
127 128
         :ok <- validate_artifact_key(key) do

@@ -130,7 +131,7 @@ defmodule OpenAgents.Forge.WAL do

130 131
  end
131 132
132 133
  @doc false
133
  def artifact_key(sha), do: "artifacts/" <> sha <> ".tar"
134
  def artifact_key(digest), do: "artifacts/" <> digest <> ".tar"
134 135
135 136
  ## Pure helpers
136 137

@@ -229,6 +230,12 @@ defmodule OpenAgents.Forge.WAL do

229 230
    end
230 231
  end
231 232
233
  defp artifact_digest(payload) do
234
    :sha256
235
    |> :crypto.hash(payload)
236
    |> Base.encode16(case: :lower)
237
  end
238
232 239
  defp adapter do
233 240
    Application.get_env(:openagents, :forge_wal_adapter, OpenAgents.Forge.WAL.Local)
234 241
  end
lib/openagents/work.ex modified +3

@@ -506,6 +506,9 @@ defmodule OpenAgents.Work do

506 506
        )
507 507
        |> repo.update()
508 508
      end)
509
      |> Multi.run(:coding_grant, fn _repo, %{job: job} ->
510
        OpenAgents.Work.Coding.settle_grant(job)
511
      end)
509 512
      |> Repo.transaction()
510 513
511 514
    case result do
lib/openagents/work/coding.ex modified +19 -23

@@ -85,44 +85,40 @@ defmodule OpenAgents.Work.Coding do

85 85
  def on_start(job), do: {:ok, job}
86 86
87 87
  @doc """
88
  Terminal cleanup, called from `Work.finish_job/3` post-commit (the one
89
  path that runs even when the worker died): remove the per-job clone and
90
  record the job's total usage against its grant in the inference ledger.
88
  Terminal filesystem cleanup, called from `Work.finish_job/3` post-commit (the
89
  one path that runs even when the worker died). Grant settlement happens
90
  inside the same transaction as the terminal job row through
91
  `settle_grant/1`.
91 92
  """
92 93
  def on_terminal(%Job{kind: @kind} = job) do
93 94
    Repository.cleanup_workspace("work-job:#{job.id}")
94
    record_grant_usage(job)
95 95
    :ok
96 96
  end
97 97
98 98
  def on_terminal(_job), do: :ok
99 99
100
  defp record_grant_usage(%Job{delegation: %{"inference_grant_id" => grant_id}} = job)
101
       when is_binary(grant_id) do
100
  @doc "Settle a coding job's metered grant inside the terminal job transaction."
101
  def settle_grant(%Job{kind: @kind, delegation: %{"inference_grant_id" => grant_id}} = job)
102
      when is_binary(grant_id) do
102 103
    case Repo.get(OpenAgents.Inference.Grant, grant_id) do
103 104
      nil ->
104
        :ok
105
        {:error, :coding_grant_missing}
105 106
106 107
      grant ->
107 108
        usage = job.usage || %{}
108 109
109
        Inference.record_usage(grant, %{
110
          "input_tokens" => Map.get(usage, "input_tokens", 0),
111
          "output_tokens" => Map.get(usage, "output_tokens", 0),
112
          "total_tokens" => Map.get(usage, "total_tokens", 0)
113
        })
114
115
        Inference.revoke(grant)
116
        :ok
110
        with {:ok, metered} <-
111
               Inference.record_usage(grant, %{
112
                 "input_tokens" => Map.get(usage, "input_tokens", 0),
113
                 "output_tokens" => Map.get(usage, "output_tokens", 0),
114
                 "total_tokens" => Map.get(usage, "total_tokens", 0)
115
               }),
116
             {:ok, settled} <- Inference.revoke(metered) do
117
          {:ok, settled}
118
        end
117 119
    end
118
  rescue
119
    error ->
120
      Logger.warning(
121
        "coding_job_grant_usage_failed code=#{OpenAgents.OperationalLog.code(error)}"
122
      )
123
124
      :ok
125 120
  end
126 121
127
  defp record_grant_usage(_job), do: :ok
122
  def settle_grant(%Job{kind: @kind}), do: {:ok, :no_grant}
123
  def settle_grant(_job), do: {:ok, :not_coding}
128 124
end
ops/forge/build-worker.exs added +1

@@ -0,0 +1,1 @@

1
OpenAgents.Forge.BuildWorker.run()
ops/staging/gate-5-profile.sh modified +2

@@ -46,6 +46,8 @@ export OPENAGENTS_FORGE_ARTIFACT_STORE="local"

46 46
export OPENAGENTS_FORGE_BUILD_DIR="/var/lib/openagents/workspace/build"
47 47
export OPENAGENTS_FORGE_BUILD_EXECUTOR="sidecar"
48 48
export OPENAGENTS_FORGE_BUILD_QUEUE_DIR="/var/lib/openagents/workspace/build-queue"
49
export OPENAGENTS_FORGE_BUILD_TIMEOUT_MS="300000"
50
export OPENAGENTS_FORGE_BUILD_OUTPUT_RETENTION_MS="604800000"
49 51
export OPENAGENTS_FORGE_DATA_DIR="/var/lib/openagents/forge"
50 52
export OPENAGENTS_FORGE_EXPECTED_FLEET_SIZE="1"
51 53
export OPENAGENTS_FORGE_INTERNAL_GIT_URL="http://127.0.0.1:8080/git"
priv/repo/migrations/20260820130000_harden_forge_build_attempt_receipts.exs added +44

@@ -0,0 +1,44 @@

1
defmodule OpenAgents.Repo.Migrations.HardenForgeBuildAttemptReceipts do
2
  use Ecto.Migration
3
4
  def change do
5
    drop_if_exists unique_index(:forge_builds, [:repo, :sha, :target_id])
6
7
    alter table(:forge_builds) do
8
      add :status, :string, null: false, default: "complete"
9
      add :baseline_manifest, :map
10
      add :manifest, :map
11
      add :artifact_digest, :string
12
      add :output_digest, :string
13
      add :output_ref, :string
14
      add :error_code, :string
15
      add :completed_at, :utc_datetime_usec
16
      add :updated_at, :utc_datetime_usec
17
    end
18
19
    execute(
20
      "UPDATE forge_builds SET completed_at = inserted_at, updated_at = inserted_at WHERE completed_at IS NULL",
21
      "SELECT 1"
22
    )
23
24
    create index(:forge_builds, [:target_id, :inserted_at])
25
    create index(:forge_builds, [:repo, :status, :inserted_at])
26
27
    create unique_index(:forge_builds, [:target_id],
28
             where: "status = 'running'",
29
             name: :forge_builds_one_running_attempt_per_target
30
           )
31
32
    create constraint(:forge_builds, :forge_builds_status_allowed,
33
             check: "status IN ('running', 'complete', 'failed', 'expired')"
34
           )
35
36
    create constraint(:forge_builds, :forge_builds_artifact_digest_shape,
37
             check: "artifact_digest IS NULL OR artifact_digest ~ '^[0-9a-f]{64}$'"
38
           )
39
40
    create constraint(:forge_builds, :forge_builds_output_digest_shape,
41
             check: "output_digest IS NULL OR output_digest ~ '^[0-9a-f]{64}$'"
42
           )
43
  end
44
end
test/openagents/forge/boot_converge_test.exs modified +34 -23

@@ -7,6 +7,7 @@ defmodule OpenAgents.Forge.BootConvergeTest do

7 7
  """
8 8
9 9
  use OpenAgents.DataCase, async: false
10
  alias OpenAgents.Forge.ArtifactFixtures
10 11
  alias OpenAgents.Forge.BootConverge
11 12
  alias OpenAgents.Forge.Repos
12 13
  alias OpenAgents.Forge.Target

@@ -67,17 +68,28 @@ defmodule OpenAgents.Forge.BootConvergeTest do

67 68
    {module, binary}
68 69
  end
69 70
71
  defp artifact(module, binary) do
72
    sha = String.duplicate("d", 40)
73
    built = ArtifactFixtures.create!(@repo, sha, [{to_string(module), binary}])
74
75
    %{
76
      built: built,
77
      details: %{
78
        "artifact" => "beams/#{built.digest}.tar",
79
        "artifact_digest" => built.digest,
80
        "build_id" => built.build_id,
81
        "modules" => Enum.map(built.beams, & &1.module)
82
      }
83
    }
84
  end
85
70 86
  test "converges to the live target's artifact and reports it" do
71 87
    {module, binary} = scratch_beam(OpenAgents.Scratch.BootConvergeProbe)
72 88
73
    artifact_abs = Path.join(Repos.data_dir(), "beams/boot-test.tar")
74
    entry_name = String.to_charlist(to_string(module) <> ".beam")
75
    :ok = :erl_tar.create(String.to_charlist(artifact_abs), [{entry_name, binary}])
76
77
    insert_target!("live", %{
78
      "artifact" => "beams/boot-test.tar",
79
      "modules" => [to_string(module)]
80
    })
89
    artifact = artifact(module, binary)
90
    artifact_abs = Path.join(Repos.data_dir(), artifact.details["artifact"])
91
    File.write!(artifact_abs, artifact.built.bytes)
92
    insert_target!("live", artifact.details)
81 93
82 94
    outcome = BootConverge.converge(@repo)
83 95
    assert %{"state" => "converged", "modules" => 1} = outcome

@@ -99,41 +111,40 @@ defmodule OpenAgents.Forge.BootConvergeTest do

99 111
             BootConverge.converge(@repo)
100 112
101 113
    # A live target whose artifact this node does not have (replaced node).
102
    insert_target!("live", %{"artifact" => "beams/not-here.tar"})
114
    {missing_module, missing_binary} = scratch_beam(OpenAgents.Scratch.BootConvergeMissing)
115
    missing = artifact(missing_module, missing_binary)
116
    insert_target!("live", missing.details)
103 117
    assert %{"state" => "image", "reason" => "artifact_missing"} = BootConverge.converge(@repo)
104 118
105 119
    # A live target with an off-allowlist module in the tar.
106
    {module, binary} = scratch_beam(BootConvergeOffLimits)
107
    artifact_abs = Path.join(Repos.data_dir(), "beams/off-allowlist.tar")
108
    entry_name = String.to_charlist(to_string(module) <> ".beam")
109
    :ok = :erl_tar.create(String.to_charlist(artifact_abs), [{entry_name, binary}])
110
    insert_target!("live", %{"artifact" => "beams/off-allowlist.tar"})
120
    {module, binary} = scratch_beam(OpenAgents.NotAllowed.BootConvergeOffLimits)
121
    off_limit = artifact(module, binary)
122
    artifact_abs = Path.join(Repos.data_dir(), off_limit.details["artifact"])
123
    File.write!(artifact_abs, off_limit.built.bytes)
124
    insert_target!("live", off_limit.details)
111 125
112 126
    assert %{"state" => "image", "reason" => "off_allowlist:" <> _rest} =
113 127
             BootConverge.converge(@repo)
114 128
115
    refute Code.ensure_loaded?(BootConvergeOffLimits)
129
    refute Code.ensure_loaded?(OpenAgents.NotAllowed.BootConvergeOffLimits)
116 130
  end
117 131
118 132
  test "a replaced node converges by fetching the artifact blob from the WAL store" do
119 133
    {module, binary} = scratch_beam(OpenAgents.Scratch.BootConvergeWalFetch)
120 134
121
    entry_name = String.to_charlist(to_string(module) <> ".beam")
122
    tar_path = Path.join(System.tmp_dir!(), "walfetch-#{System.unique_integer([:positive])}.tar")
123
    :ok = :erl_tar.create(String.to_charlist(tar_path), [{entry_name, binary}])
135
    artifact = artifact(module, binary)
124 136
125
    sha = String.duplicate("d", 40)
126
    {:ok, _key} = OpenAgents.Forge.WAL.put_artifact(@repo, sha, File.read!(tar_path))
127
    File.rm!(tar_path)
137
    {:ok, _key} =
138
      OpenAgents.Forge.WAL.put_artifact(@repo, artifact.built.digest, artifact.built.bytes)
128 139
129 140
    # The target names an artifact path that does NOT exist locally — the
130 141
    # blob store is the only copy, exactly a replaced node's situation.
131
    insert_target!("live", %{"artifact" => "beams/#{sha}.tar"})
142
    insert_target!("live", artifact.details)
132 143
133 144
    assert %{"state" => "converged", "modules" => 1} = BootConverge.converge(@repo)
134 145
    assert module.marker() == :boot_converged
135 146
    # The fetched blob is now local cache for next boot.
136
    assert File.exists?(Path.join(Repos.data_dir(), "beams/#{sha}.tar"))
147
    assert File.exists?(Path.join(Repos.data_dir(), artifact.details["artifact"]))
137 148
138 149
    :code.purge(module)
139 150
    :code.delete(module)
test/openagents/forge/build_artifact_test.exs added +213

@@ -0,0 +1,213 @@

1
defmodule OpenAgents.Forge.BuildArtifactTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Forge.BuildArtifact
5
  alias OpenAgents.Forge.BuildProtocol
6
7
  @repo "openagents.com"
8
  @sha String.duplicate("a", 40)
9
  @next_sha String.duplicate("b", 40)
10
11
  test "the JSON build protocol rejects executable, unknown, stale, and mismatched fields" do
12
    build_id = Ecto.UUID.generate()
13
    target_id = Ecto.UUID.generate()
14
15
    request =
16
      BuildProtocol.request!(%{
17
        build_id: build_id,
18
        repo: @repo,
19
        source_sha: @sha,
20
        target_id: target_id,
21
        repo_url: "http://forge.internal/git/openagents.com.git",
22
        baseline_manifest: nil,
23
        expires_at: DateTime.utc_now() |> DateTime.add(300, :second) |> DateTime.to_iso8601()
24
      })
25
26
    assert {:ok, encoded} = BuildProtocol.encode_request(request)
27
    assert {:ok, ^request} = BuildProtocol.decode_request(encoded)
28
    refute encoded =~ "SHA="
29
    refute encoded =~ "source "
30
31
    assert {:error, :unexpected_fields} =
32
             request
33
             |> Map.put("shell", "$(touch /tmp/owned)")
34
             |> BuildProtocol.validate_request()
35
36
    assert {:error, :invalid_repo_url} =
37
             request
38
             |> Map.put("repo_url", "https://token@forge.internal/git/openagents.com.git")
39
             |> BuildProtocol.validate_request()
40
41
    response =
42
      BuildProtocol.ok_response(build_id, %{
43
        artifact_digest: String.duplicate("f", 64),
44
        artifact_ref: "artifacts/#{String.duplicate("f", 64)}.tar",
45
        output_digest: String.duplicate("e", 64),
46
        output_ref: "output/#{build_id}.log",
47
        duration_ms: 42
48
      })
49
50
    assert {:ok, response_json} = BuildProtocol.encode_response(response)
51
    assert {:ok, ^response} = BuildProtocol.decode_response(response_json)
52
53
    assert {:error, {:invalid_uuid, :build_id}} =
54
             response
55
             |> Map.put("build_id", "not-a-build-id")
56
             |> BuildProtocol.validate_response()
57
  end
58
59
  test "protocol writes queue files by atomic same-directory rename" do
60
    dir = Path.join(System.tmp_dir!(), "build-protocol-#{System.unique_integer([:positive])}")
61
    path = Path.join(dir, "request.json")
62
    on_exit(fn -> File.rm_rf(dir) end)
63
64
    assert :ok = BuildProtocol.atomic_write(path, "{}")
65
    assert File.read!(path) == "{}"
66
    assert {:ok, %{mode: mode}} = File.stat(path)
67
    assert Bitwise.band(mode, 0o777) == 0o600
68
    assert {:ok, ["request.json"]} = File.ls(dir)
69
    assert {:error, :destination_exists} = BuildProtocol.atomic_write(path, "replacement")
70
    assert File.read!(path) == "{}"
71
  end
72
73
  test "artifacts are reproducible and bind manifest, digest, changes, and BEAM identity" do
74
    baseline_id = Ecto.UUID.generate()
75
    candidate_id = Ecto.UUID.generate()
76
    toolchain = BuildArtifact.current_toolchain()
77
78
    baseline_beams = [compile_beam("ReproducibleProbe", 1)]
79
80
    assert {:ok, baseline} =
81
             BuildArtifact.pack(@repo, @sha, baseline_id, baseline_beams, toolchain: toolchain)
82
83
    candidate_beams = [compile_beam("ReproducibleProbe", 2), compile_beam("AddedProbe", 3)]
84
85
    opts = [baseline_manifest: baseline.manifest, toolchain: toolchain]
86
87
    assert {:ok, candidate} =
88
             BuildArtifact.pack(@repo, @next_sha, candidate_id, candidate_beams, opts)
89
90
    assert {:ok, repeated} =
91
             BuildArtifact.pack(@repo, @next_sha, candidate_id, candidate_beams, opts)
92
93
    assert repeated.bytes == candidate.bytes
94
    assert repeated.digest == candidate.digest
95
    assert candidate.manifest["classification"] == "direct_candidate"
96
    assert candidate.manifest["changes"]["added"] == ["Elixir.OpenAgents.Scratch.AddedProbe"]
97
98
    assert candidate.manifest["changes"]["changed"] == [
99
             "Elixir.OpenAgents.Scratch.ReproducibleProbe"
100
           ]
101
102
    assert candidate.manifest["changes"]["deleted"] == []
103
104
    assert {:ok, verified} =
105
             BuildArtifact.verify(candidate.bytes,
106
               digest: candidate.digest,
107
               repo: @repo,
108
               source_sha: @next_sha,
109
               build_id: candidate_id
110
             )
111
112
    assert verified.modules == [
113
             "Elixir.OpenAgents.Scratch.AddedProbe",
114
             "Elixir.OpenAgents.Scratch.ReproducibleProbe"
115
           ]
116
117
    assert {:error, :artifact_digest_mismatch} =
118
             BuildArtifact.verify(candidate.bytes, digest: String.duplicate("0", 64))
119
  end
120
121
  test "deletions and toolchain drift route away from direct loading" do
122
    toolchain = BuildArtifact.current_toolchain()
123
124
    assert {:ok, baseline} =
125
             BuildArtifact.pack(
126
               @repo,
127
               @sha,
128
               Ecto.UUID.generate(),
129
               [compile_beam("KeepProbe", 1), compile_beam("DeleteProbe", 1)],
130
               toolchain: toolchain
131
             )
132
133
    drifted = Map.put(toolchain, "otp", "different")
134
135
    assert {:ok, candidate} =
136
             BuildArtifact.pack(
137
               @repo,
138
               @next_sha,
139
               Ecto.UUID.generate(),
140
               [compile_beam("KeepProbe", 1)],
141
               baseline_manifest: baseline.manifest,
142
               toolchain: drifted,
143
               structural_reasons: ["config_changed", "nif_changed"]
144
             )
145
146
    assert candidate.manifest["classification"] == "needs_rolling_replace"
147
148
    assert candidate.manifest["structural_reasons"] == [
149
             "config_changed",
150
             "module_deletion",
151
             "nif_changed",
152
             "toolchain_otp_changed"
153
           ]
154
155
    assert candidate.manifest["changes"]["deleted"] == [
156
             "Elixir.OpenAgents.Scratch.DeleteProbe"
157
           ]
158
  end
159
160
  test "malformed entry paths and mismatched internal module names fail before atomization" do
161
    beam = compile_beam("IdentityProbe", 1)
162
    build_id = Ecto.UUID.generate()
163
164
    assert {:ok, artifact} =
165
             BuildArtifact.pack(@repo, @sha, build_id, [beam],
166
               toolchain: BuildArtifact.current_toolchain()
167
             )
168
169
    {:ok, entries} = :erl_tar.extract({:binary, artifact.bytes}, [:memory])
170
171
    replaced =
172
      Enum.map(entries, fn
173
        {~c"beams/Elixir.OpenAgents.Scratch.IdentityProbe.beam", binary} ->
174
          {~c"beams/Elixir.OpenAgents.Scratch.Impostor.beam", binary}
175
176
        entry ->
177
          entry
178
      end)
179
180
    tampered = tar_bytes(replaced)
181
    assert {:error, :undeclared_module} = BuildArtifact.verify(tampered)
182
183
    traversal =
184
      Enum.map(entries, fn
185
        {~c"beams/Elixir.OpenAgents.Scratch.IdentityProbe.beam", binary} ->
186
          {~c"../escape.beam", binary}
187
188
        entry ->
189
          entry
190
      end)
191
192
    assert {:error, :invalid_artifact_entry} = BuildArtifact.verify(tar_bytes(traversal))
193
  end
194
195
  defp compile_beam(suffix, value) do
196
    module = "OpenAgents.Scratch.#{suffix}"
197
198
    [{atom, binary}] =
199
      Code.compile_string("defmodule #{module} do\n def value, do: #{value}\nend")
200
201
    :code.purge(atom)
202
    :code.delete(atom)
203
    %{module: "Elixir." <> module, binary: binary}
204
  end
205
206
  defp tar_bytes(entries) do
207
    path = Path.join(System.tmp_dir!(), "artifact-test-#{System.unique_integer([:positive])}.tar")
208
    :ok = :erl_tar.create(String.to_charlist(path), entries)
209
    bytes = File.read!(path)
210
    File.rm!(path)
211
    bytes
212
  end
213
end
test/openagents/forge/build_worker_test.exs added +181

@@ -0,0 +1,181 @@

1
defmodule OpenAgents.Forge.BuildWorkerTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Forge.ArtifactFixtures
5
  alias OpenAgents.Forge.BuildArtifact
6
  alias OpenAgents.Forge.BuildProtocol
7
  alias OpenAgents.Forge.BuildWorker
8
9
  @repo "openagents.com"
10
  @sha String.duplicate("c", 40)
11
12
  setup do
13
    root = Path.join(System.tmp_dir!(), "build-worker-#{System.unique_integer([:positive])}")
14
    queue = Path.join(root, "queue")
15
    artifacts = Path.join(root, "artifacts")
16
    builds = Path.join(root, "builds")
17
    File.mkdir_p!(root)
18
    on_exit(fn -> File.rm_rf(root) end)
19
    %{queue: queue, artifacts: artifacts, builds: builds}
20
  end
21
22
  test "one JSON request produces an atomic verified artifact, bounded response, and retained log",
23
       %{queue: queue, artifacts: artifacts, builds: builds} do
24
    build_id = Ecto.UUID.generate()
25
    baseline = ArtifactFixtures.create!(@repo, String.duplicate("b", 40), []).manifest
26
    request = request(build_id, baseline)
27
    write_request!(queue, request)
28
29
    source = "defmodule OpenAgents.Scratch.WorkerProbe do\n def value, do: 42\nend"
30
    [{module, binary}] = Code.compile_string(source)
31
    :code.purge(module)
32
    :code.delete(module)
33
    retained = String.duplicate("compiler-output\n", 2_000)
34
35
    build_fun = fn claimed, workspace ->
36
      assert claimed["build_id"] == build_id
37
      assert claimed["source_sha"] == @sha
38
      assert Path.basename(workspace) == build_id
39
40
      {:ok, [%{module: Atom.to_string(module), binary: binary}],
41
       BuildArtifact.current_toolchain(), [], retained}
42
    end
43
44
    assert :processed =
45
             BuildWorker.run_once(queue, artifacts, builds, build_fun: build_fun)
46
47
    response_path = Path.join([queue, "responses", build_id <> ".json"])
48
    assert {:ok, response} = response_path |> File.read!() |> BuildProtocol.decode_response()
49
    assert response["status"] == "ok"
50
    assert response["build_id"] == build_id
51
52
    artifact_path = Path.join(artifacts, response["artifact_ref"])
53
54
    assert {:ok, verified} =
55
             artifact_path
56
             |> File.read!()
57
             |> BuildArtifact.verify(
58
               digest: response["artifact_digest"],
59
               repo: @repo,
60
               source_sha: @sha,
61
               build_id: build_id
62
             )
63
64
    assert verified.modules == ["Elixir.OpenAgents.Scratch.WorkerProbe"]
65
    assert verified.manifest["baseline"]["source_sha"] == String.duplicate("b", 40)
66
67
    output_path = Path.join(artifacts, response["output_ref"])
68
    assert File.read!(output_path) == retained
69
    assert response["output_digest"] == BuildArtifact.digest(retained)
70
    assert byte_size(response["output_excerpt"]) == 8_192
71
    assert {:ok, %{mode: mode}} = File.stat(output_path)
72
    assert Bitwise.band(mode, 0o777) == 0o600
73
74
    assert {:ok, []} = File.ls(Path.join(queue, "requests"))
75
    assert {:ok, []} = File.ls(Path.join(queue, "running"))
76
    refute File.exists?(Path.join([builds, "jobs", build_id]))
77
  end
78
79
  test "unknown request fields fail before the build callback runs", context do
80
    build_id = Ecto.UUID.generate()
81
    request = request(build_id, nil) |> Map.put("shell", "$(touch /tmp/owned)")
82
    encoded = Jason.encode!(request)
83
    path = Path.join([context.queue, "requests", build_id <> ".json"])
84
    File.mkdir_p!(Path.dirname(path))
85
    File.write!(path, encoded)
86
87
    build_fun = fn _request, _workspace -> flunk("malformed request reached compiler") end
88
89
    assert :processed =
90
             BuildWorker.run_once(context.queue, context.artifacts, context.builds,
91
               build_fun: build_fun
92
             )
93
94
    response =
95
      context.queue
96
      |> Path.join("responses/#{build_id}.json")
97
      |> File.read!()
98
      |> then(fn bytes -> elem(BuildProtocol.decode_response(bytes), 1) end)
99
100
    assert response["status"] == "error"
101
    assert response["error_code"] == "unexpected_fields"
102
    assert Path.wildcard(Path.join(context.artifacts, "artifacts/*.tar")) == []
103
  end
104
105
  test "operator-only full output expires under the defined retention", context do
106
    output_dir = Path.join(context.artifacts, "output")
107
    File.mkdir_p!(output_dir)
108
    old_log = Path.join(output_dir, Ecto.UUID.generate() <> ".log")
109
    File.write!(old_log, "retained compiler output")
110
    File.touch!(old_log, System.os_time(:second) - 2 * 24 * 60 * 60)
111
112
    assert :idle =
113
             BuildWorker.run_once(context.queue, context.artifacts, context.builds,
114
               output_retention_ms: 24 * 60 * 60 * 1000
115
             )
116
117
    refute File.exists?(old_log)
118
  end
119
120
  test "expired request IDs cannot be revived by a later attempt", context do
121
    expired_id = Ecto.UUID.generate()
122
    fresh_id = Ecto.UUID.generate()
123
    expired = request(expired_id, nil, DateTime.add(DateTime.utc_now(), -1, :second))
124
    write_request!(context.queue, expired)
125
126
    assert :processed =
127
             BuildWorker.run_once(context.queue, context.artifacts, context.builds,
128
               build_fun: fn _request, _workspace -> flunk("expired request reached compiler") end
129
             )
130
131
    expired_response = read_response!(context.queue, expired_id)
132
    assert expired_response["status"] == "error"
133
    assert expired_response["error_code"] == "request_expired"
134
135
    baseline = ArtifactFixtures.create!(@repo, String.duplicate("b", 40), []).manifest
136
    write_request!(context.queue, request(fresh_id, baseline))
137
    [{module, binary}] = Code.compile_string("defmodule OpenAgents.Scratch.FreshAttempt do\nend")
138
    :code.purge(module)
139
    :code.delete(module)
140
141
    build_fun = fn request, _workspace ->
142
      assert request["build_id"] == fresh_id
143
144
      {:ok, [%{module: Atom.to_string(module), binary: binary}],
145
       BuildArtifact.current_toolchain(), [], "ok"}
146
    end
147
148
    assert :processed =
149
             BuildWorker.run_once(context.queue, context.artifacts, context.builds,
150
               build_fun: build_fun
151
             )
152
153
    fresh_response = read_response!(context.queue, fresh_id)
154
    assert fresh_response["status"] == "ok"
155
    assert fresh_response["build_id"] != expired_response["build_id"]
156
  end
157
158
  defp request(build_id, baseline, expires_at \\ DateTime.add(DateTime.utc_now(), 300, :second)) do
159
    BuildProtocol.request!(%{
160
      build_id: build_id,
161
      repo: @repo,
162
      source_sha: @sha,
163
      target_id: Ecto.UUID.generate(),
164
      repo_url: "http://forge.internal/git/openagents.com.git",
165
      baseline_manifest: baseline,
166
      expires_at: DateTime.to_iso8601(expires_at)
167
    })
168
  end
169
170
  defp write_request!(queue, request) do
171
    {:ok, encoded} = BuildProtocol.encode_request(request)
172
    path = Path.join([queue, "requests", request["build_id"] <> ".json"])
173
    :ok = BuildProtocol.atomic_write(path, encoded)
174
  end
175
176
  defp read_response!(queue, build_id) do
177
    bytes = File.read!(Path.join([queue, "responses", build_id <> ".json"]))
178
    {:ok, response} = BuildProtocol.decode_response(bytes)
179
    response
180
  end
181
end
test/openagents/forge/builder_test.exs modified +97 -60

@@ -1,21 +1,16 @@

1 1
defmodule OpenAgents.Forge.BuilderTest do
2 2
  use OpenAgents.DataCase, async: false
3
  import Ecto.Query
3 4
  alias OpenAgents.Forge.BuildExecutor
4 5
  alias OpenAgents.Forge.BuildExecutor.Sidecar
5 6
  alias OpenAgents.Forge.BuildReceipt
6 7
  alias OpenAgents.Forge.Builder
7 8
  alias OpenAgents.Forge.FakeBuildExecutor
8 9
  alias OpenAgents.Forge.Repos
10
  alias OpenAgents.Forge.Target
9 11
  alias OpenAgents.Forge.Targets
10 12
11
  describe "pure Sidecar adapter pieces" do
12
    test "render_job serializes the two env-style lines the watcher sources" do
13
      assert Sidecar.render_job("abc123", "http://127.0.0.1:8080/git/openagents.com.git") ==
14
               "SHA=abc123\nREPO_URL=http://127.0.0.1:8080/git/openagents.com.git\n"
15
16
      refute Sidecar.render_job("abc123", "http://127.0.0.1/repo.git") =~ "token"
17
    end
18
13
  describe "Sidecar adapter boundaries" do
19 14
    test "sidecar repository URLs never contain the operator credential" do
20 15
      previous_url = Application.get_env(:openagents, :forge_internal_git_url)
21 16
      previous_token = Application.get_env(:openagents, :forge_operator_token)

@@ -34,50 +29,6 @@ defmodule OpenAgents.Forge.BuilderTest do

34 29
      refute Sidecar.repo_url("openagents.com") =~ "forge-secret-sentinel"
35 30
    end
36 31
37
    test "parse_result reads env-style lines, tolerating garbage" do
38
      contents = """
39
      STATUS=ok
40
      MODULES=Elixir.Foo,Elixir.Bar
41
      DURATION=7
42
      garbage-single-token
43
      """
44
45
      assert Sidecar.parse_result(contents) == %{
46
               "STATUS" => "ok",
47
               "MODULES" => "Elixir.Foo,Elixir.Bar",
48
               "DURATION" => "7"
49
             }
50
51
      assert Sidecar.parse_result("") == %{}
52
      assert Sidecar.parse_result("STATUS=error\n") == %{"STATUS" => "error"}
53
    end
54
55
    test "beams_from_tar reads entries back out of a beam tar, sorted" do
56
      tar = Path.join(System.tmp_dir!(), "beams-#{System.unique_integer([:positive])}.tar")
57
58
      :ok =
59
        :erl_tar.create(String.to_charlist(tar), [
60
          {~c"Elixir.Zeta.beam", "zeta-bytes"},
61
          {~c"Elixir.Alpha.beam", "alpha-bytes"}
62
        ])
63
64
      bytes = File.read!(tar)
65
      File.rm!(tar)
66
67
      assert {:ok,
68
              [
69
                %{module: "Elixir.Alpha", binary: "alpha-bytes"},
70
                %{module: "Elixir.Zeta", binary: "zeta-bytes"}
71
              ]} = Sidecar.beams_from_tar(bytes)
72
73
      assert {:error, _reason} = Sidecar.beams_from_tar("not a tar")
74
    end
75
76
    test "module_name strips path and extension" do
77
      assert Sidecar.module_name("ebin/Elixir.Foo.Bar.beam") == "Elixir.Foo.Bar"
78
      assert Sidecar.module_name("Elixir.Foo.beam") == "Elixir.Foo"
79
    end
80
81 32
    test "bound_output truncates past the bound" do
82 33
      assert BuildExecutor.bound_output("short") == "short"
83 34

@@ -150,14 +101,17 @@ defmodule OpenAgents.Forge.BuilderTest do

150 101
151 102
      assert target_id == target.id
152 103
153
      # Artifact tar exists at <data_dir>/beams/<sha>.tar with beam entries.
154
      assert artifact == Path.join([data_dir, "beams", sha <> ".tar"])
104
      # Artifact cache is addressed by the full tar digest, never by source SHA.
105
      assert Path.dirname(artifact) == Path.join(data_dir, "beams")
106
      assert Path.basename(artifact) =~ ~r/^[0-9a-f]{64}\.tar$/
155 107
      assert File.exists?(artifact)
156 108
157 109
      {:ok, entries} = :erl_tar.extract(String.to_charlist(artifact), [:memory])
158
      assert [{name, binary}] = entries
159
      assert to_string(name) == module <> ".beam"
160
      assert binary == beam.binary
110
      assert Enum.any?(entries, fn {name, _binary} -> to_string(name) == "manifest.json" end)
111
112
      assert Enum.any?(entries, fn {name, _binary} ->
113
               to_string(name) == "beams/#{module}.beam"
114
             end)
161 115
162 116
      # Receipt row.
163 117
      receipt = Repo.get_by!(BuildReceipt, repo: "openagents.com", sha: sha)

@@ -166,11 +120,16 @@ defmodule OpenAgents.Forge.BuilderTest do

166 120
      assert receipt.warnings == "warn: something minor"
167 121
      assert receipt.tests == nil
168 122
      assert receipt.duration_ms == 123
169
      assert receipt.artifact == Path.join("beams", sha <> ".tar")
123
      assert receipt.status == "complete"
124
      assert receipt.artifact_digest =~ ~r/^[0-9a-f]{64}$/
125
      assert receipt.artifact == Path.join("beams", receipt.artifact_digest <> ".tar")
126
      assert receipt.manifest["source_sha"] == sha
170 127
171 128
      # Target advanced to built with artifact + modules in details.
172 129
      built = await_status(target, "built")
173
      assert built.details["artifact"] == Path.join("beams", sha <> ".tar")
130
      assert built.details["artifact"] == receipt.artifact
131
      assert built.details["artifact_digest"] == receipt.artifact_digest
132
      assert built.details["build_id"] == receipt.id
174 133
      assert built.details["modules"] == [module]
175 134
    end
176 135

@@ -185,9 +144,13 @@ defmodule OpenAgents.Forge.BuilderTest do

185 144
186 145
      failed = await_status(target, "failed")
187 146
      assert failed.status == "failed"
188
      assert String.starts_with?(failed.details["error"], "boom")
147
      assert String.starts_with?(failed.details["error"], "build_failed: boom")
189 148
      assert byte_size(failed.details["error"]) <= 8_192 + byte_size("\n[truncated]")
190 149
150
      receipt = Repo.get_by!(BuildReceipt, target_id: target.id)
151
      assert receipt.status == "failed"
152
      assert receipt.error_code == "build_failed"
153
191 154
      refute_receive {:forge_build_ready, _payload}, 200
192 155
193 156
      # The Builder survived the failed build and handles the next one.

@@ -210,6 +173,80 @@ defmodule OpenAgents.Forge.BuilderTest do

210 173
      target2_id = target2.id
211 174
      assert_receive {:forge_build_ready, %{sha: ^sha, target_id: ^target2_id}}, 5_000
212 175
    end
176
177
    test "recovery expires an abandoned build ID before creating a new attempt", %{sha: sha} do
178
      previous_threshold =
179
        Application.get_env(:openagents, :forge_build_abandoned_after_ms)
180
181
      Application.put_env(:openagents, :forge_build_abandoned_after_ms, 0)
182
183
      on_exit(fn ->
184
        if previous_threshold,
185
          do:
186
            Application.put_env(
187
              :openagents,
188
              :forge_build_abandoned_after_ms,
189
              previous_threshold
190
            ),
191
          else: Application.delete_env(:openagents, :forge_build_abandoned_after_ms)
192
      end)
193
194
      suffix = System.unique_integer([:positive])
195
196
      beams =
197
        FakeBuildExecutor.beams_for("""
198
        defmodule OpenAgents.Scratch.RecoveredBuild#{suffix} do
199
          def ok, do: :ok
200
        end
201
        """)
202
203
      Application.put_env(
204
        :openagents,
205
        :fake_build_result,
206
        {:ok, %{beams: beams, warnings: "", tests: nil, duration_ms: 1}}
207
      )
208
209
      target =
210
        %Target{}
211
        |> Target.changeset(%{
212
          repo: "openagents.com",
213
          sha: sha,
214
          promoted_by: "test-operator",
215
          status: "promoted"
216
        })
217
        |> Repo.insert!()
218
        |> Ecto.Changeset.change(%{status: "building"})
219
        |> Repo.update!()
220
221
      abandoned_id = Ecto.UUID.generate()
222
223
      %BuildReceipt{id: abandoned_id}
224
      |> BuildReceipt.start_changeset(%{
225
        repo: target.repo,
226
        sha: target.sha,
227
        target_id: target.id,
228
        baseline_manifest: nil
229
      })
230
      |> Repo.insert!()
231
232
      builder = Process.whereis(Builder)
233
      send(builder, :recover_abandoned)
234
      _state = :sys.get_state(builder)
235
236
      assert_receive {:forge_build_ready, %{target_id: target_id, build_id: recovered_id}}, 5_000
237
      assert target_id == target.id
238
      refute recovered_id == abandoned_id
239
240
      receipts =
241
        BuildReceipt
242
        |> where([b], b.target_id == ^target.id)
243
        |> order_by([b], asc: b.inserted_at)
244
        |> Repo.all()
245
246
      assert Enum.map(receipts, & &1.status) == ["expired", "complete"]
247
      assert Enum.map(receipts, & &1.id) == [abandoned_id, recovered_id]
248
      assert Enum.at(receipts, 0).error_code == "builder_restart_expired"
249
    end
213 250
  end
214 251
215 252
  # The Builder does its DB work asynchronously; poll the target row until
test/openagents/forge/hot_loader_test.exs modified +42 -48

@@ -2,6 +2,7 @@ defmodule OpenAgents.Forge.HotLoaderTest do

2 2
  use OpenAgents.DataCase, async: false
3 3
  @moduletag :capture_log
4 4
5
  alias OpenAgents.Forge.ArtifactFixtures
5 6
  alias OpenAgents.Forge.DeployReceipt
6 7
  alias OpenAgents.Forge.HotLoader
7 8
  alias OpenAgents.Forge.PushReceipt

@@ -31,7 +32,7 @@ defmodule OpenAgents.Forge.HotLoaderTest do

31 32
  # itself is what brings it into the running system.
32 33
  defp compiled_scratch_module do
33 34
    n = System.unique_integer([:positive])
34
    name = "OpenAgents.Scratch.HotDemo#{n}"
35
    name = "Elixir.OpenAgents.Scratch.HotDemo#{n}"
35 36
    revision = "v#{n}"
36 37
37 38
    [{mod, binary}] =

@@ -48,13 +49,27 @@ defmodule OpenAgents.Forge.HotLoaderTest do

48 49
    :code.purge(mod)
49 50
  end
50 51
51
  defp tar_artifact(entries) do
52
    dir = System.tmp_dir!()
53
    path = Path.join(dir, "forge-hot-#{System.unique_integer([:positive])}.tar")
52
  defp artifact(entries, sha) do
53
    built = ArtifactFixtures.create!("openagents.com", sha, entries)
54
    path = ArtifactFixtures.write!(built)
55
    on_exit(fn -> File.rm(path) end)
56
57
    %{
58
      artifact: path,
59
      artifact_digest: built.digest,
60
      build_id: built.build_id,
61
      manifest: built.manifest,
62
      modules: Enum.map(built.beams, & &1.module)
63
    }
64
  end
65
66
  defp malformed_artifact(entries) do
67
    path =
68
      Path.join(System.tmp_dir!(), "forge-malformed-#{System.unique_integer([:positive])}.tar")
54 69
55 70
    tar_entries =
56 71
      Enum.map(entries, fn {module_name, binary} ->
57
        {String.to_charlist("Elixir.#{module_name}.beam"), binary}
72
        {String.to_charlist("beams/#{module_name}.beam"), binary}
58 73
      end)
59 74
60 75
    :ok = :erl_tar.create(String.to_charlist(path), tar_entries)

@@ -62,6 +77,10 @@ defmodule OpenAgents.Forge.HotLoaderTest do

62 77
    path
63 78
  end
64 79
80
  defp build_payload(target, sha, artifact) do
81
    Map.merge(artifact, %{repo: "openagents.com", sha: sha, target_id: target.id})
82
  end
83
65 84
  defp insert_target(sha, status) do
66 85
    {:ok, target} =
67 86
      %Target{}

@@ -97,17 +116,11 @@ defmodule OpenAgents.Forge.HotLoaderTest do

97 116
98 117
    sha = unique_sha()
99 118
    target = insert_target(sha, "built")
100
    artifact = tar_artifact([{name, binary}])
119
    artifact = artifact([{name, binary}], sha)
101 120
102 121
    Phoenix.PubSub.subscribe(OpenAgents.PubSub, @deploys_topic)
103 122
104
    broadcast_build_ready(loader, %{
105
      repo: "openagents.com",
106
      sha: sha,
107
      target_id: target.id,
108
      artifact: artifact,
109
      modules: [name]
110
    })
123
    broadcast_build_ready(loader, build_payload(target, sha, artifact))
111 124
112 125
    assert Code.ensure_loaded?(mod)
113 126
    assert mod.revision() == revision

@@ -132,17 +145,14 @@ defmodule OpenAgents.Forge.HotLoaderTest do

132 145
133 146
    sha = unique_sha()
134 147
    target = insert_target(sha, "built")
135
    artifact = tar_artifact([{name, binary}, {"OpenAgents.Turns.Whatever", <<0>>}])
148
    off_name = "Elixir.OpenAgents.Turns.Whatever#{System.unique_integer([:positive])}"
149
    [{off_mod, off_binary}] = Code.compile_string("defmodule #{off_name} do\nend")
150
    unload(off_mod)
151
    artifact = artifact([{name, binary}, {off_name, off_binary}], sha)
136 152
137 153
    Phoenix.PubSub.subscribe(OpenAgents.PubSub, @deploys_topic)
138 154
139
    broadcast_build_ready(loader, %{
140
      repo: "openagents.com",
141
      sha: sha,
142
      target_id: target.id,
143
      artifact: artifact,
144
      modules: [name, "OpenAgents.Turns.Whatever"]
145
    })
155
    broadcast_build_ready(loader, build_payload(target, sha, artifact))
146 156
147 157
    # Never a partial load: even the allowlisted module stays unloaded.
148 158
    refute Code.ensure_loaded?(mod)

@@ -152,21 +162,19 @@ defmodule OpenAgents.Forge.HotLoaderTest do

152 162
    receipt = deploy_receipt(sha)
153 163
    assert receipt.result == "needs_rolling_replace"
154 164
155
    offenders = Repo.get!(Target, target.id).details["modules"]
156
    assert offenders == ["OpenAgents.Turns.Whatever"]
165
    assert Repo.get!(Target, target.id).details["reasons"] == ["off_allowlist:#{off_name}"]
157 166
158 167
    assert_receive {:forge_deploy,
159 168
                    %{repo: "openagents.com", sha: ^sha, result: "needs_rolling_replace"}}
160 169
  end
161 170
162
  test "canary revert: a corrupt beam reverts the whole artifact", %{loader: loader} do
171
  test "a corrupt artifact fails verification before creating module atoms", %{loader: loader} do
163 172
    %{mod: mod, name: name, binary: binary} = compiled_scratch_module()
164
    corrupt_name = "OpenAgents.Scratch.Corrupt#{System.unique_integer([:positive])}"
165
    corrupt_mod = String.to_atom("Elixir.#{corrupt_name}")
173
    corrupt_name = "Elixir.OpenAgents.Scratch.Corrupt#{System.unique_integer([:positive])}"
166 174
167 175
    sha = unique_sha()
168 176
    target = insert_target(sha, "built")
169
    artifact = tar_artifact([{name, binary}, {corrupt_name, <<1, 2, 3>>}])
177
    artifact = malformed_artifact([{name, binary}, {corrupt_name, <<1, 2, 3>>}])
170 178
171 179
    Phoenix.PubSub.subscribe(OpenAgents.PubSub, @deploys_topic)
172 180

@@ -180,12 +188,10 @@ defmodule OpenAgents.Forge.HotLoaderTest do

180 188
181 189
    # The good module loaded first must have been reverted (purged) too.
182 190
    refute Code.ensure_loaded?(mod)
183
    refute Code.ensure_loaded?(corrupt_mod)
191
    assert Repo.get!(Target, target.id).status == "failed"
192
    assert deploy_receipt(sha).result == "failed"
184 193
185
    assert Repo.get!(Target, target.id).status == "reverted"
186
    assert deploy_receipt(sha).result == "reverted"
187
188
    assert_receive {:forge_deploy, %{repo: "openagents.com", sha: ^sha, result: "reverted"}}
194
    assert_receive {:forge_deploy, %{repo: "openagents.com", sha: ^sha, result: "failed"}}
189 195
  end
190 196
191 197
  test "push_to_live_ms is measured from the matching push receipt", %{loader: loader} do

@@ -193,7 +199,7 @@ defmodule OpenAgents.Forge.HotLoaderTest do

193 199
194 200
    sha = unique_sha()
195 201
    target = insert_target(sha, "built")
196
    artifact = tar_artifact([{name, binary}])
202
    artifact = artifact([{name, binary}], sha)
197 203
198 204
    {:ok, _push} =
199 205
      %PushReceipt{}

@@ -205,13 +211,7 @@ defmodule OpenAgents.Forge.HotLoaderTest do

205 211
      })
206 212
      |> Repo.insert()
207 213
208
    broadcast_build_ready(loader, %{
209
      repo: "openagents.com",
210
      sha: sha,
211
      target_id: target.id,
212
      artifact: artifact,
213
      modules: [name]
214
    })
214
    broadcast_build_ready(loader, build_payload(target, sha, artifact))
215 215
216 216
    receipt = deploy_receipt(sha)
217 217
    assert receipt.result == "live"

@@ -224,15 +224,9 @@ defmodule OpenAgents.Forge.HotLoaderTest do

224 224
225 225
    sha = unique_sha()
226 226
    target = insert_target(sha, "built")
227
    artifact = tar_artifact([{name, binary}])
227
    artifact = artifact([{name, binary}], sha)
228 228
229
    broadcast_build_ready(loader, %{
230
      repo: "openagents.com",
231
      sha: sha,
232
      target_id: target.id,
233
      artifact: artifact,
234
      modules: [name]
235
    })
229
    broadcast_build_ready(loader, build_payload(target, sha, artifact))
236 230
237 231
    receipt = deploy_receipt(sha)
238 232
    assert receipt.result == "live"
test/openagents/forge/wal_test.exs modified +21

@@ -113,6 +113,27 @@ defmodule OpenAgents.Forge.WALTest do

113 113
    end
114 114
  end
115 115
116
  describe "digest-addressed artifacts" do
117
    test "round trips only under the payload's full SHA-256" do
118
      payload = :crypto.strong_rand_bytes(512)
119
120
      digest =
121
        :sha256
122
        |> :crypto.hash(payload)
123
        |> Base.encode16(case: :lower)
124
125
      assert {:ok, "artifacts/" <> ^digest <> ".tar"} =
126
               WAL.put_artifact(@repo, digest, payload)
127
128
      assert {:ok, ^payload} = WAL.get_artifact(@repo, digest)
129
130
      assert {:error, :artifact_digest_mismatch} =
131
               WAL.put_artifact(@repo, String.duplicate("0", 64), payload)
132
133
      assert {:error, :invalid_object_key} = WAL.get_artifact(@repo, String.duplicate("a", 40))
134
    end
135
  end
136
116 137
  describe "repo validation" do
117 138
    test "rejects invalid repo names on every dispatcher function" do
118 139
      for bad <- ["Uppercase", "a/b", "", "-lead", "bad..git", :openagents] do
test/support/forge/artifact_fixtures.ex added +56

@@ -0,0 +1,56 @@

1
defmodule OpenAgents.Forge.ArtifactFixtures do
2
  @moduledoc false
3
4
  alias OpenAgents.Forge.BuildArtifact
5
6
  def create!(repo, sha, beams, opts \\ []) do
7
    toolchain = Keyword.get(opts, :toolchain, BuildArtifact.current_toolchain())
8
    baseline = Keyword.get(opts, :baseline_manifest, baseline!(repo, toolchain))
9
    build_id = Keyword.get(opts, :build_id, Ecto.UUID.generate())
10
11
    normalized_beams =
12
      Enum.map(beams, fn
13
        %{module: module, binary: binary} ->
14
          %{module: ensure_elixir_prefix(module), binary: binary}
15
16
        {module, binary} ->
17
          %{module: ensure_elixir_prefix(module), binary: binary}
18
      end)
19
20
    {:ok, artifact} =
21
      BuildArtifact.pack(repo, sha, build_id, normalized_beams,
22
        baseline_manifest: baseline,
23
        toolchain: toolchain,
24
        structural_reasons: Keyword.get(opts, :structural_reasons, [])
25
      )
26
27
    Map.put(artifact, :build_id, build_id)
28
  end
29
30
  def write!(artifact) do
31
    path =
32
      Path.join(
33
        System.tmp_dir!(),
34
        "forge-artifact-#{System.unique_integer([:positive])}-#{artifact.digest}.tar"
35
      )
36
37
    File.write!(path, artifact.bytes)
38
    path
39
  end
40
41
  defp baseline!(repo, toolchain) do
42
    {:ok, artifact} =
43
      BuildArtifact.pack(
44
        repo,
45
        String.duplicate("0", 40),
46
        Ecto.UUID.generate(),
47
        [],
48
        toolchain: toolchain
49
      )
50
51
    artifact.manifest
52
  end
53
54
  defp ensure_elixir_prefix("Elixir." <> _rest = module), do: module
55
  defp ensure_elixir_prefix(module), do: "Elixir." <> module
56
end
test/support/forge/fake_build_executor.ex modified +49 -3

@@ -8,9 +8,42 @@ defmodule OpenAgents.Forge.FakeBuildExecutor do

8 8
  hot-load) get genuine loadable beams.
9 9
  """
10 10
11
  def build(_repo, _sha, _opts) do
12
    Application.get_env(:openagents, :fake_build_result) ||
13
      {:error, "no :fake_build_result configured"}
11
  alias OpenAgents.Forge.BuildArtifact
12
13
  def build(repo, sha, opts) do
14
    case Application.get_env(:openagents, :fake_build_result) do
15
      {:ok, %{artifact_bytes: _bytes} = result} ->
16
        {:ok, result}
17
18
      {:ok, %{beams: beams} = scripted} ->
19
        toolchain = BuildArtifact.current_toolchain()
20
        baseline = Keyword.get(opts, :baseline_manifest) || synthetic_baseline(repo, toolchain)
21
22
        with {:ok, artifact} <-
23
               BuildArtifact.pack(repo, sha, Keyword.fetch!(opts, :build_id), beams,
24
                 baseline_manifest: baseline,
25
                 toolchain: toolchain
26
               ) do
27
          {:ok,
28
           %{
29
             artifact_bytes: artifact.bytes,
30
             artifact_digest: artifact.digest,
31
             manifest: artifact.manifest,
32
             beams: artifact.beams,
33
             warnings: Map.get(scripted, :warnings, ""),
34
             tests: Map.get(scripted, :tests),
35
             duration_ms: Map.get(scripted, :duration_ms, 1),
36
             output_digest: nil,
37
             output_ref: nil
38
           }}
39
        end
40
41
      {:error, _output} = error ->
42
        error
43
44
      nil ->
45
        {:error, "no :fake_build_result configured"}
46
    end
14 47
  end
15 48
16 49
  @doc "A full scripted `{:ok, build_result}` for `source` (compiled for real)."

@@ -28,4 +61,17 @@ defmodule OpenAgents.Forge.FakeBuildExecutor do

28 61
      %{module: Atom.to_string(module), binary: binary}
29 62
    end)
30 63
  end
64
65
  defp synthetic_baseline(repo, toolchain) do
66
    {:ok, artifact} =
67
      BuildArtifact.pack(
68
        repo,
69
        String.duplicate("0", 40),
70
        Ecto.UUID.generate(),
71
        [],
72
        toolchain: toolchain
73
      )
74
75
    artifact.manifest
76
  end
31 77
end

This page updates live while a promote is in flight · changelog