Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add contract evolution tests for server-pushed schemas

Adds automated testing that enforces schema evolution rules on
server-pushed schemas (Deployment, SeedDeployment, Seed, etc.),
catching breaking changes that would prevent old gardens from
decoding payloads from a newer server.

- mix sower.update_contract_baseline: regenerates the baseline
- Contract evolution tests check: no new required fields, no
removed properties, no type changes
- Baseline regenerated during `just release`

Ticket: SOW-85

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+322 -16
+4 -1
CLAUDE.md
··· 13 13 14 14 ## Definition of done 15 15 - formatting done, `just format` 16 - - tests pass, `just check-elixir`, `just check-go`, or `just check-e2e` 16 + - tests pass, 17 + - `just check-elixir` 18 + - `just check-go`- 19 + - and importantly, `just check-e2e` 17 20 - code committed with all ticket changes included 18 21 - Ticket ID in the body 19 22 - Co-Authored-By line always included
+94
apps/sower_client/lib/mix/tasks/sower.update_contract_baseline.ex
··· 1 + defmodule Mix.Tasks.Sower.UpdateContractBaseline do 2 + @moduledoc """ 3 + Regenerates the contract baseline file from current server-pushed schemas. 4 + 5 + The baseline captures the shape of schemas that the server pushes to gardens. 6 + Contract evolution tests compare live schemas against this baseline to catch 7 + breaking changes that would prevent old gardens from decoding deployments. 8 + 9 + ## Usage 10 + 11 + mix sower.update_contract_baseline 12 + 13 + ## Output 14 + 15 + Writes `test/fixtures/contract_baseline.json` with the structural shape of 16 + each server-pushed schema (property names, types, required fields, defaults, 17 + nullability). 18 + """ 19 + 20 + use Mix.Task 21 + 22 + @baseline_path "apps/sower_client/test/fixtures/contract_baseline.json" 23 + 24 + @impl Mix.Task 25 + def run(_args) do 26 + Mix.Task.run("compile") 27 + 28 + spec = SowerClient.spec() 29 + 30 + baseline = 31 + SowerClient.server_pushed_schema_titles() 32 + |> Enum.map(fn title -> 33 + schema = Map.fetch!(spec.components.schemas, title) 34 + {title, extract_shape(schema)} 35 + end) 36 + |> Enum.into(%{}) 37 + 38 + json = Jason.encode!(baseline, pretty: true) 39 + File.mkdir_p!(Path.dirname(@baseline_path)) 40 + File.write!(@baseline_path, json <> "\n") 41 + 42 + Mix.shell().info("Wrote contract baseline to #{@baseline_path}") 43 + end 44 + 45 + defp extract_shape(schema) do 46 + properties = 47 + (schema.properties || %{}) 48 + |> Enum.map(fn {name, prop} -> {to_string(name), extract_property(prop)} end) 49 + |> Enum.sort() 50 + |> Enum.into(%{}) 51 + 52 + required = 53 + (schema.required || []) 54 + |> Enum.map(&to_string/1) 55 + |> Enum.sort() 56 + 57 + %{"required" => required, "properties" => properties} 58 + end 59 + 60 + defp extract_property(%OpenApiSpex.Reference{"$ref": ref}) do 61 + title = ref |> String.split("/") |> List.last() 62 + %{"type" => "object", "ref" => title} 63 + end 64 + 65 + defp extract_property(%OpenApiSpex.Schema{} = prop) do 66 + shape = %{"type" => to_string(prop.type)} 67 + 68 + shape = 69 + if prop.default != nil do 70 + Map.put(shape, "has_default", true) 71 + else 72 + shape 73 + end 74 + 75 + shape = 76 + if prop.nullable do 77 + Map.put(shape, "nullable", true) 78 + else 79 + shape 80 + end 81 + 82 + shape = 83 + case prop.items do 84 + %OpenApiSpex.Reference{"$ref": ref} -> 85 + title = ref |> String.split("/") |> List.last() 86 + Map.put(shape, "items_ref", title) 87 + 88 + _ -> 89 + shape 90 + end 91 + 92 + shape 93 + end 94 + end
+14
apps/sower_client/lib/sower_client.ex
··· 1 1 defmodule SowerClient do 2 + # Schemas the server pushes TO gardens (broadcasts + replies). 3 + # Changes to these can break old gardens that haven't upgraded. 4 + # Used by contract evolution tests and baseline generation. 5 + @server_pushed_schema_titles [ 6 + "Deployment", 7 + "SeedDeployment", 8 + "DeploymentProfile", 9 + "Seed", 10 + "SeedTag", 11 + "PresignedUploadReply" 12 + ] 13 + 14 + def server_pushed_schema_titles, do: @server_pushed_schema_titles 15 + 2 16 def spec() do 3 17 %OpenApiSpex.OpenApi{ 4 18 info: %OpenApiSpex.Info{
-14
apps/sower_client/lib/sower_client/orchestration/deployment_error.ex
··· 1 - defmodule SowerClient.Orchestration.DeploymentError do 2 - use SowerClient.Schema 3 - use SowerClient.ChannelMessage, event: "deployment:error" 4 - 5 - OpenApiSpex.schema(%{ 6 - title: "DeploymentError", 7 - type: :object, 8 - properties: %{ 9 - request_id: %Schema{type: :string, description: "Original request ID"}, 10 - reason: %Schema{type: :string, description: "Error reason"} 11 - }, 12 - required: ~w(request_id reason)a 13 - }) 14 - end
+112
apps/sower_client/test/fixtures/contract_baseline.json
··· 1 + { 2 + "Deployment": { 3 + "properties": { 4 + "deployed_at": { 5 + "has_default": true, 6 + "type": "string" 7 + }, 8 + "request_id": { 9 + "type": "string" 10 + }, 11 + "seed_deployments": { 12 + "items_ref": "SeedDeployment", 13 + "type": "array" 14 + }, 15 + "sid": { 16 + "type": "string" 17 + }, 18 + "skipped": { 19 + "has_default": true, 20 + "type": "boolean" 21 + } 22 + }, 23 + "required": [] 24 + }, 25 + "DeploymentProfile": { 26 + "properties": { 27 + "activation_args": { 28 + "has_default": true, 29 + "type": "array" 30 + }, 31 + "reboot_policy": { 32 + "has_default": true, 33 + "type": "string" 34 + } 35 + }, 36 + "required": [] 37 + }, 38 + "PresignedUploadReply": { 39 + "properties": { 40 + "headers": { 41 + "has_default": true, 42 + "type": "object" 43 + }, 44 + "method": { 45 + "has_default": true, 46 + "type": "string" 47 + }, 48 + "url": { 49 + "type": "string" 50 + } 51 + }, 52 + "required": [ 53 + "headers", 54 + "method", 55 + "url" 56 + ] 57 + }, 58 + "Seed": { 59 + "properties": { 60 + "artifact": { 61 + "type": "string" 62 + }, 63 + "name": { 64 + "type": "string" 65 + }, 66 + "seed_type": { 67 + "type": "string" 68 + }, 69 + "sid": { 70 + "nullable": true, 71 + "type": "string" 72 + }, 73 + "tags": { 74 + "has_default": true, 75 + "items_ref": "SeedTag", 76 + "type": "array" 77 + } 78 + }, 79 + "required": [ 80 + "artifact", 81 + "name", 82 + "seed_type" 83 + ] 84 + }, 85 + "SeedDeployment": { 86 + "properties": { 87 + "seed": { 88 + "ref": "Seed", 89 + "type": "object" 90 + }, 91 + "subscription_sid": { 92 + "nullable": true, 93 + "type": "string" 94 + } 95 + }, 96 + "required": [] 97 + }, 98 + "SeedTag": { 99 + "properties": { 100 + "key": { 101 + "type": "string" 102 + }, 103 + "value": { 104 + "type": "string" 105 + } 106 + }, 107 + "required": [ 108 + "key", 109 + "value" 110 + ] 111 + } 112 + }
+96
apps/sower_client/test/sower_client/contract_evolution_test.exs
··· 1 + defmodule SowerClient.ContractEvolutionTest do 2 + use ExUnit.Case, async: true 3 + 4 + @moduledoc """ 5 + Verifies schema evolution rules on server-pushed schemas. 6 + 7 + The server pushes these schemas to gardens (e.g. Deployment broadcasts). 8 + Old gardens must be able to decode payloads from newer servers, so we 9 + enforce: 10 + 11 + - No new required fields (old clients won't send/expect them) 12 + - No removed properties (old clients may still reference them) 13 + - No type changes (old clients expect the original type) 14 + 15 + To intentionally update the contract (with a migration path), run: 16 + 17 + mix sower.update_contract_baseline 18 + """ 19 + 20 + @baseline_path Path.expand("../fixtures/contract_baseline.json", __DIR__) 21 + 22 + @server_pushed_schemas SowerClient.server_pushed_schema_titles() 23 + 24 + setup_all do 25 + baseline = @baseline_path |> File.read!() |> Jason.decode!() 26 + spec = SowerClient.spec() 27 + {:ok, baseline: baseline, spec: spec} 28 + end 29 + 30 + for title <- @server_pushed_schemas do 31 + describe "#{title}" do 32 + test "no new required fields", %{baseline: baseline, spec: spec} do 33 + old = baseline[unquote(title)] 34 + current = spec.components.schemas[unquote(title)] 35 + 36 + old_required = MapSet.new(old["required"]) 37 + 38 + new_required = 39 + (current.required || []) 40 + |> Enum.map(&to_string/1) 41 + |> MapSet.new() 42 + 43 + added = MapSet.difference(new_required, old_required) 44 + 45 + assert MapSet.size(added) == 0, 46 + "#{unquote(title)}: added required fields #{inspect(MapSet.to_list(added))}. " <> 47 + "Old gardens won't send these. Make them optional with defaults, " <> 48 + "or run `mix sower.update_contract_baseline` after ensuring a migration path." 49 + end 50 + 51 + test "no removed properties", %{baseline: baseline, spec: spec} do 52 + old = baseline[unquote(title)] 53 + current = spec.components.schemas[unquote(title)] 54 + 55 + old_props = MapSet.new(Map.keys(old["properties"])) 56 + 57 + new_props = 58 + current.properties 59 + |> Map.keys() 60 + |> Enum.map(&to_string/1) 61 + |> MapSet.new() 62 + 63 + removed = MapSet.difference(old_props, new_props) 64 + 65 + assert MapSet.size(removed) == 0, 66 + "#{unquote(title)}: removed properties #{inspect(MapSet.to_list(removed))}. " <> 67 + "Old gardens may still reference these. " <> 68 + "Run `mix sower.update_contract_baseline` after ensuring a migration path." 69 + end 70 + 71 + test "no type changes", %{baseline: baseline, spec: spec} do 72 + old = baseline[unquote(title)] 73 + current = spec.components.schemas[unquote(title)] 74 + 75 + for {prop_name, old_prop} <- old["properties"] do 76 + case Map.get(current.properties, String.to_existing_atom(prop_name)) do 77 + %OpenApiSpex.Schema{} = current_prop -> 78 + assert to_string(current_prop.type) == old_prop["type"], 79 + "#{unquote(title)}.#{prop_name}: type changed " <> 80 + "from #{old_prop["type"]} to #{current_prop.type}. " <> 81 + "Run `mix sower.update_contract_baseline` after ensuring a migration path." 82 + 83 + %OpenApiSpex.Reference{} -> 84 + assert old_prop["type"] == "object", 85 + "#{unquote(title)}.#{prop_name}: was #{old_prop["type"]}, " <> 86 + "now a $ref (object). " <> 87 + "Run `mix sower.update_contract_baseline` after ensuring a migration path." 88 + 89 + nil -> 90 + :ok 91 + end 92 + end 93 + end 94 + end 95 + end 96 + end
+2 -1
justfile
··· 82 82 # just dev-seed-from-local 83 83 84 84 release: set-version 85 - git add VERSION openapi.json 85 + mix sower.update_contract_baseline 86 + git add VERSION openapi.json apps/sower_client/test/fixtures/contract_baseline.json 86 87 jj commit -m "release: version $(cat VERSION)" 87 88 88 89 release-push: