Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

agent/server: authorize deployment log uploads

+319 -68
+74 -26
apps/sower/lib/sower/storage.ex
··· 1 1 defmodule Sower.Storage do 2 2 require Logger 3 3 4 - alias SowerClient.Storage.PresignUploadReply 5 - alias SowerClient.Storage.PresignUploadRequest 4 + alias Sower.Orchestration 5 + alias Sower.Orchestration.Agent 6 + alias SowerClient.Storage.PresignedUploadReply 7 + alias SowerClient.Storage.DeploymentLogUploadRequest 8 + 9 + @doc """ 10 + Generates a presigned URL for deployment log upload with authorization checks. 6 11 7 - def presign_upload(%PresignUploadRequest{} = request) do 8 - method = request.method || "PUT" 12 + Validates that: 13 + 1. The deployment exists 14 + 2. The deployment belongs to the requesting agent 15 + 3. The seed is associated with the deployment 9 16 10 - if method != "PUT" do 11 - {:error, :unsupported_method} 17 + Returns {:ok, PresignedUploadReply} on success, {:error, reason} on failure. 18 + """ 19 + def presign_deployment_log_upload( 20 + %Agent{} = agent, 21 + %DeploymentLogUploadRequest{} = request, 22 + presign_upload_fun \\ &presign_upload/2 23 + ) do 24 + with {:ok, deployment} <- fetch_deployment(request.deployment_sid), 25 + :ok <- verify_deployment_ownership(deployment, agent), 26 + :ok <- verify_seed_in_deployment(deployment, request.seed_sid), 27 + object_path = 28 + SowerClient.Orchestration.SeedDeployment.log_path( 29 + request.deployment_sid, 30 + request.seed_sid 31 + ), 32 + {:ok, url} <- presign_upload_fun.(object_path, presign_upload_opts(request)) do 33 + {:ok, 34 + PresignedUploadReply.cast!(%{ 35 + url: url, 36 + method: "PUT", 37 + headers: presign_upload_headers(request) 38 + })} 12 39 else 13 - opts = presign_upload_opts(request) 40 + {:error, :deployment_not_found} -> 41 + {:error, :unauthorized} 14 42 15 - case presign_upload(request.path, opts) do 16 - {:ok, url} -> 17 - {:ok, 18 - PresignUploadReply.cast!(%{ 19 - url: url, 20 - method: method, 21 - headers: presign_upload_headers(request) 22 - })} 43 + {:error, :unauthorized} -> 44 + {:error, :unauthorized} 23 45 24 - {:error, reason} -> 25 - Logger.error( 26 - msg: "Failed to presign upload", 27 - path: request.path, 28 - method: method, 29 - reason: inspect(reason) 30 - ) 46 + {:error, :seed_not_in_deployment} -> 47 + {:error, :seed_not_in_deployment} 48 + 49 + {:error, reason} -> 50 + Logger.error( 51 + msg: "Failed to presign deployment log upload", 52 + deployment_sid: request.deployment_sid, 53 + seed_sid: request.seed_sid, 54 + agent_sid: agent.sid, 55 + reason: inspect(reason) 56 + ) 31 57 32 - {:error, :failed_to_presign_upload} 33 - end 58 + {:error, :failed_to_presign_upload} 59 + end 60 + end 61 + 62 + defp fetch_deployment(deployment_sid) do 63 + case Orchestration.get_deployment_sid(deployment_sid) do 64 + nil -> {:error, :deployment_not_found} 65 + deployment -> {:ok, Sower.Repo.preload(deployment, :seeds)} 66 + end 67 + end 68 + 69 + defp verify_deployment_ownership(deployment, agent) do 70 + if deployment.agent_id == agent.id do 71 + :ok 72 + else 73 + {:error, :unauthorized} 74 + end 75 + end 76 + 77 + defp verify_seed_in_deployment(deployment, seed_sid) do 78 + if Enum.any?(deployment.seeds, &(&1.sid == seed_sid)) do 79 + :ok 80 + else 81 + {:error, :seed_not_in_deployment} 34 82 end 35 83 end 36 84 ··· 81 129 end 82 130 end 83 131 84 - defp presign_upload_opts(%PresignUploadRequest{} = request) do 132 + defp presign_upload_opts(%DeploymentLogUploadRequest{} = request) do 85 133 case request.checksum_sha256 do 86 134 nil -> [] 87 135 checksum -> [checksum_sha256: checksum] 88 136 end 89 137 end 90 138 91 - defp presign_upload_headers(%PresignUploadRequest{} = request) do 139 + defp presign_upload_headers(%DeploymentLogUploadRequest{} = request) do 92 140 case request.checksum_sha256 do 93 141 nil -> %{} 94 142 checksum -> %{"x-amz-checksum-sha256" => checksum}
+3 -1
apps/sower/lib/sower_web/agent_channel.ex
··· 138 138 Orchestration.update_agent_seed_generations(report, socket.assigns.agent) 139 139 end) 140 140 141 - handle_schema(SowerClient.Storage.PresignUploadRequest, &Sower.Storage.presign_upload/1) 141 + handle_schema(SowerClient.Storage.DeploymentLogUploadRequest, fn req, socket -> 142 + Sower.Storage.presign_deployment_log_upload(socket.assigns.agent, req) 143 + end) 142 144 143 145 @impl Phoenix.Channel 144 146 def handle_info(:track_presence, %Phoenix.Socket{assigns: %{agent: agent}} = socket) do
+108
apps/sower/test/sower/storage_test.exs
··· 1 + defmodule Sower.StorageTest do 2 + use Sower.DataCase 3 + 4 + alias Sower.Orchestration 5 + alias Sower.Storage 6 + alias SowerClient.Storage.DeploymentLogUploadRequest 7 + alias SowerClient.Storage.PresignedUploadReply 8 + 9 + import Sower.AccountsFixtures 10 + import Sower.OrchestrationFixtures 11 + import Sower.SeedFixtures 12 + 13 + setup do 14 + org = organization_fixture() 15 + Sower.Repo.put_org_id(org.org_id) 16 + 17 + %{organization: org} 18 + end 19 + 20 + describe "presign_deployment_log_upload/2" do 21 + setup %{organization: org} do 22 + agent = agent_fixture(%{org_id: org.org_id}) 23 + other_agent = agent_fixture(%{org_id: org.org_id, name: "other agent"}) 24 + 25 + seed = seed_fixture(%{org_id: org.org_id}) 26 + 27 + deployment = 28 + deployment_fixture(%{ 29 + org_id: org.org_id, 30 + agent_id: agent.id, 31 + seeds: [seed], 32 + subscriptions: [] 33 + }) 34 + 35 + deployment = Sower.Repo.preload(deployment, :seeds) 36 + 37 + %{agent: agent, other_agent: other_agent, deployment: deployment, seed: seed} 38 + end 39 + 40 + test "returns presigned upload URL for valid request", %{ 41 + agent: agent, 42 + deployment: deployment, 43 + seed: seed 44 + } do 45 + request = %DeploymentLogUploadRequest{ 46 + deployment_sid: deployment.sid, 47 + seed_sid: seed.sid, 48 + checksum_sha256: nil 49 + } 50 + 51 + # Mock the presign_upload function to avoid S3 dependency 52 + mock_presign = fn path, _opts -> 53 + {:ok, "https://mock-s3.example.com/#{path}?signed=true"} 54 + end 55 + 56 + assert {:ok, %PresignedUploadReply{} = reply} = 57 + Storage.presign_deployment_log_upload(agent, request, mock_presign) 58 + 59 + assert reply.url == 60 + "https://mock-s3.example.com/logs/deployments/#{deployment.sid}/seeds/#{seed.sid}.log?signed=true" 61 + 62 + assert reply.method == "PUT" 63 + assert reply.headers == %{} 64 + end 65 + 66 + test "returns error for non-existent deployment", %{agent: agent} do 67 + request = %DeploymentLogUploadRequest{ 68 + deployment_sid: "nonexistent_deploy", 69 + seed_sid: "some_seed", 70 + checksum_sha256: nil 71 + } 72 + 73 + assert {:error, :unauthorized} = 74 + Storage.presign_deployment_log_upload(agent, request) 75 + end 76 + 77 + test "returns unauthorized error for deployment owned by different agent", %{ 78 + other_agent: other_agent, 79 + deployment: deployment, 80 + seed: seed 81 + } do 82 + request = %DeploymentLogUploadRequest{ 83 + deployment_sid: deployment.sid, 84 + seed_sid: seed.sid, 85 + checksum_sha256: nil 86 + } 87 + 88 + assert {:error, :unauthorized} = 89 + Storage.presign_deployment_log_upload(other_agent, request) 90 + end 91 + 92 + test "returns error when seed is not associated with deployment", %{ 93 + agent: agent, 94 + deployment: deployment 95 + } do 96 + other_seed = seed_fixture(%{org_id: agent.org_id, name: "other_seed"}) 97 + 98 + request = %DeploymentLogUploadRequest{ 99 + deployment_sid: deployment.sid, 100 + seed_sid: other_seed.sid, 101 + checksum_sha256: nil 102 + } 103 + 104 + assert {:error, :seed_not_in_deployment} = 105 + Storage.presign_deployment_log_upload(agent, request) 106 + end 107 + end 108 + end
+11 -11
apps/sower_agent/lib/sower_agent/deployer.ex
··· 8 8 alias SowerClient.Orchestration.Deployment 9 9 alias SowerClient.Orchestration.DeploymentProfile 10 10 alias SowerClient.Orchestration.SeedDeployment 11 - alias SowerClient.Storage.PresignUploadReply 12 - alias SowerClient.Storage.PresignUploadRequest 11 + alias SowerClient.Storage.PresignedUploadReply 12 + alias SowerClient.Storage.DeploymentLogUploadRequest 13 13 14 14 def run(%Deployment{} = deployment) do 15 15 result = ··· 319 319 object_path = SeedDeployment.log_path(deployment.sid, seed.sid) 320 320 321 321 request = 322 - PresignUploadRequest.cast!(%{ 323 - path: object_path, 324 - method: "PUT", 322 + DeploymentLogUploadRequest.cast!(%{ 323 + deployment_sid: deployment.sid, 324 + seed_sid: seed.sid, 325 325 checksum_sha256: checksum_sha256 326 326 }) 327 327 328 - case Client.call(PresignUploadRequest.event(), request, 15_000) do 328 + case Client.call(DeploymentLogUploadRequest.event(), request, 15_000) do 329 329 {:ok, reply_payload} -> 330 - case PresignUploadReply.cast(reply_payload) do 330 + case PresignedUploadReply.cast(reply_payload) do 331 331 {:ok, reply} -> 332 332 upload_deployment_log(reply, content, deployment.sid, seed.sid, object_path) 333 333 334 334 {:error, error} -> 335 335 Logger.error( 336 - msg: "Failed to parse presign upload reply", 336 + msg: "Failed to parse deployment log upload reply", 337 337 deployment_sid: deployment.sid, 338 338 seed_sid: seed.sid, 339 339 object_path: object_path, ··· 343 343 344 344 {:error, error} -> 345 345 Logger.error( 346 - msg: "Failed to request presign upload URL", 346 + msg: "Failed to request deployment log upload URL", 347 347 deployment_sid: deployment.sid, 348 348 seed_sid: seed.sid, 349 349 object_path: object_path, ··· 353 353 end 354 354 355 355 defp upload_deployment_log( 356 - %PresignUploadReply{} = reply, 356 + %PresignedUploadReply{} = reply, 357 357 content, 358 358 deployment_sid, 359 359 seed_sid, ··· 375 375 end 376 376 377 377 defp do_upload_deployment_log( 378 - %PresignUploadReply{} = reply, 378 + %PresignedUploadReply{} = reply, 379 379 content, 380 380 deployment_sid, 381 381 seed_sid,
+2 -2
apps/sower_client/lib/sower_client.ex
··· 21 21 SowerClient.Orchestration.SeedDeployment, 22 22 SowerClient.Orchestration.Subscription, 23 23 SowerClient.Orchestration.SubscriptionSync, 24 - SowerClient.Storage.PresignUploadReply, 25 - SowerClient.Storage.PresignUploadRequest, 24 + SowerClient.Storage.PresignedUploadReply, 25 + SowerClient.Storage.DeploymentLogUploadRequest, 26 26 SowerClient.Seed, 27 27 SowerClient.SeedMeta, 28 28 SowerClient.SeedTag
+25
apps/sower_client/lib/sower_client/storage/deployment_log_upload_request.ex
··· 1 + defmodule SowerClient.Storage.DeploymentLogUploadRequest do 2 + use SowerClient.Schema 3 + use SowerClient.ChannelMessage, event: "storage:deployment_log_upload" 4 + 5 + OpenApiSpex.schema(%{ 6 + title: "DeploymentLogUploadRequest", 7 + type: :object, 8 + properties: %{ 9 + deployment_sid: %Schema{ 10 + type: :string, 11 + description: "SID of the deployment the log belongs to" 12 + }, 13 + seed_sid: %Schema{ 14 + type: :string, 15 + description: "SID of the seed within the deployment" 16 + }, 17 + checksum_sha256: %Schema{ 18 + type: :string, 19 + description: "Base64 SHA-256 checksum to sign for upload integrity", 20 + nullable: true 21 + } 22 + }, 23 + required: [:deployment_sid, :seed_sid] 24 + }) 25 + end
+9 -2
apps/sower_client/lib/sower_client/storage/presign_upload_reply.ex apps/sower_client/lib/sower_client/storage/presigned_upload_reply.ex
··· 1 - defmodule SowerClient.Storage.PresignUploadReply do 1 + defmodule SowerClient.Storage.PresignedUploadReply do 2 2 use SowerClient.Schema 3 3 4 + @moduledoc """ 5 + Generic reply for presigned upload URL requests. 6 + 7 + Used across different upload contexts (deployment logs, etc.). 8 + Contains the URL, HTTP method, and required headers for the upload. 9 + """ 10 + 4 11 OpenApiSpex.schema(%{ 5 - title: "PresignUploadReply", 12 + title: "PresignedUploadReply", 6 13 type: :object, 7 14 properties: %{ 8 15 url: %Schema{
-26
apps/sower_client/lib/sower_client/storage/presign_upload_request.ex
··· 1 - defmodule SowerClient.Storage.PresignUploadRequest do 2 - use SowerClient.Schema 3 - use SowerClient.ChannelMessage, event: "storage:presign_upload" 4 - 5 - OpenApiSpex.schema(%{ 6 - title: "PresignUploadRequest", 7 - type: :object, 8 - properties: %{ 9 - path: %Schema{ 10 - type: :string, 11 - description: "Path of object in storage bucket" 12 - }, 13 - method: %Schema{ 14 - type: :string, 15 - description: "HTTP method to be used with signed URL", 16 - default: "PUT" 17 - }, 18 - checksum_sha256: %Schema{ 19 - type: :string, 20 - description: "Base64 SHA-256 checksum to sign for upload integrity", 21 - nullable: true 22 - } 23 - }, 24 - required: [:path] 25 - }) 26 - end
+87
apps/sower_client/test/sower_client/storage/deployment_log_upload_test.exs
··· 1 + defmodule SowerClient.Storage.DeploymentLogUploadTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias SowerClient.Storage.DeploymentLogUploadRequest 5 + alias SowerClient.Storage.PresignedUploadReply 6 + 7 + describe "DeploymentLogUploadRequest" do 8 + test "cast/1 with valid data succeeds" do 9 + assert {:ok, request} = 10 + DeploymentLogUploadRequest.cast(%{ 11 + "deployment_sid" => "deploy_123", 12 + "seed_sid" => "seed_456", 13 + "checksum_sha256" => "abc123" 14 + }) 15 + 16 + assert request.deployment_sid == "deploy_123" 17 + assert request.seed_sid == "seed_456" 18 + assert request.checksum_sha256 == "abc123" 19 + end 20 + 21 + test "cast/1 without checksum succeeds" do 22 + assert {:ok, request} = 23 + DeploymentLogUploadRequest.cast(%{ 24 + "deployment_sid" => "deploy_123", 25 + "seed_sid" => "seed_456" 26 + }) 27 + 28 + assert request.deployment_sid == "deploy_123" 29 + assert request.seed_sid == "seed_456" 30 + assert request.checksum_sha256 == nil 31 + end 32 + 33 + test "cast/1 with missing deployment_sid fails" do 34 + assert {:error, _} = 35 + DeploymentLogUploadRequest.cast(%{ 36 + "seed_sid" => "seed_456" 37 + }) 38 + end 39 + 40 + test "cast/1 with missing seed_sid fails" do 41 + assert {:error, _} = 42 + DeploymentLogUploadRequest.cast(%{ 43 + "deployment_sid" => "deploy_123" 44 + }) 45 + end 46 + 47 + test "event/0 returns correct channel event" do 48 + assert DeploymentLogUploadRequest.event() == "storage:deployment_log_upload" 49 + end 50 + end 51 + 52 + describe "PresignedUploadReply" do 53 + test "cast/1 with valid data succeeds" do 54 + assert {:ok, reply} = 55 + PresignedUploadReply.cast(%{ 56 + "url" => "https://s3.example.com/upload", 57 + "method" => "PUT", 58 + "headers" => %{"x-amz-checksum-sha256" => "abc123"} 59 + }) 60 + 61 + assert reply.url == "https://s3.example.com/upload" 62 + assert reply.method == "PUT" 63 + assert reply.headers == %{"x-amz-checksum-sha256" => "abc123"} 64 + end 65 + 66 + test "cast!/1 with valid data returns struct" do 67 + reply = 68 + PresignedUploadReply.cast!(%{ 69 + "url" => "https://s3.example.com/upload", 70 + "method" => "PUT", 71 + "headers" => %{} 72 + }) 73 + 74 + assert reply.url == "https://s3.example.com/upload" 75 + assert reply.method == "PUT" 76 + assert reply.headers == %{} 77 + end 78 + 79 + test "cast/1 with missing required fields fails" do 80 + assert {:error, _} = 81 + PresignedUploadReply.cast(%{ 82 + "method" => "PUT", 83 + "headers" => %{} 84 + }) 85 + end 86 + end 87 + end