durable-actors 0.7.2

Standalone regional durable-actors control plane, host, and durability runtime
Documentation
use super::*;
use crate::{
    control_plane::{
        ActorJwtIssuer, ActorJwtVerifier, ActorTokenPurpose, session::InvocationGrant,
    },
    host::{http::ActorHostHttpService, sockets::HostSockets},
};
use aws_lc_rs::{rand::SystemRandom, signature::Ed25519KeyPair};
use base64::{Engine, engine::general_purpose::STANDARD};

#[tokio::test]
async fn draining_host_rejects_http_invocations_without_executing_them() -> Result<()> {
    let mut fixture = HttpHost::start(None).await?;
    assert_eq!(
        fixture.invoke("warm", "counter-1").await?,
        json!({"type":"completed", "result":1})
    );
    assert_eq!(fixture.started.recv().await.as_deref(), Some("warm"));
    fixture.host.drain(Duration::from_secs(1)).await?;
    assert_eq!(
        fixture.invoke("rejected", "counter-1").await?,
        json!({"type":"not_executed", "reason":"host_unavailable"})
    );
    fixture.host.drain(Duration::from_secs(1)).await?;
    assert!(fixture.started.try_recv().is_err());
    assert!(fixture.host.queues().inventory().is_empty());
    Ok(())
}

#[tokio::test]
async fn host_assigned_to_another_actor_rejects_before_execution() -> Result<()> {
    let mut fixture = HttpHost::start(None).await?;
    fixture.invoke("warm", "counter-1").await?;
    assert_eq!(fixture.started.recv().await.as_deref(), Some("warm"));
    assert_eq!(
        fixture.invoke("wrong-host", "another").await?,
        json!({"type":"not_executed", "reason":"stale_owner"})
    );
    assert!(fixture.started.try_recv().is_err());
    assert_eq!(
        fixture.invoke("next", "counter-1").await?,
        json!({"type":"completed", "result":2})
    );
    Ok(())
}

#[tokio::test]
async fn interrupted_execution_is_an_unknown_outcome_over_http() -> Result<()> {
    let mut fixture = HttpHost::start(None).await?;
    let reply = fixture.invoke("panic", "counter-1").await?;
    assert_eq!(fixture.started.recv().await.as_deref(), Some("panic"));
    assert_eq!(reply["type"], "failed");
    assert_eq!(reply["code"], "outcome_unknown");
    Ok(())
}

#[tokio::test]
async fn delegated_permissions_are_enforced_before_retryable_admission() -> Result<()> {
    for allowed in [true, false] {
        let grant = InvocationGrant {
            subject: "caller".into(),
            grant_id: "session".into(),
            expires_at: i64::MAX,
            methods: if allowed {
                vec!["increment".into()]
            } else {
                vec!["read".into()]
            },
        };
        let mut fixture = HttpHost::start(Some(grant)).await?;
        fixture.host.drain(Duration::from_secs(1)).await?;
        let reply = fixture.invoke("delegated", "counter-1").await?;
        if allowed {
            assert_eq!(
                reply,
                json!({"type":"not_executed", "reason":"host_unavailable"})
            );
        } else {
            assert_eq!(reply["type"], "failed");
            assert_eq!(reply["code"], "forbidden");
        }
        assert!(fixture.started.try_recv().is_err());
    }
    Ok(())
}

struct HttpHost {
    host: Arc<ActorHost>,
    started: mpsc::UnboundedReceiver<String>,
    issuer: ActorJwtIssuer,
    grant: Option<InvocationGrant>,
    origin: String,
    client: reqwest::Client,
    _tasks: JoinSet<()>,
}

impl HttpHost {
    async fn start(grant: Option<InvocationGrant>) -> Result<Self> {
        let (started_tx, started) = mpsc::unbounded_channel();
        let storage = Arc::new(FakeAuthority::default());
        let sockets = Arc::new(HostSockets::new(storage.clone()));
        let host = Arc::new(ActorHost::new(
            HostEndpoint {
                id: crate::host::HostId::new("host.v3.revision-1.host-1"),
                route: "http://host.invalid".into(),
            },
            Arc::new(ControlledExecutor {
                started: started_tx,
                release: Arc::new(tokio::sync::Semaphore::new(0)),
            }),
            storage,
            Arc::new(FakeStateTransport::default()),
            sockets.clone(),
        ));
        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new())?;
        let issuer = ActorJwtIssuer::from_base64_pkcs8(
            &STANDARD.encode(pkcs8.as_ref()),
            "key",
            "issuer",
            "authority",
            "invocation",
            Duration::from_secs(60),
        )?;
        let auth = ActorJwtVerifier::for_scope(
            issuer.verifier_keys_json()?,
            "issuer",
            "invocation",
            ActorTokenPurpose::Invocation,
            Duration::from_secs(60),
        )?;
        let service = ActorHostHttpService::new(
            host.clone(),
            "00000000-0000-4000-8000-000000000001".into(),
            auth,
            sockets,
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
        let origin = format!("http://{}", listener.local_addr()?);
        let mut tasks = JoinSet::new();
        tasks.spawn(async move {
            axum::serve(listener, service.router()).await.unwrap();
        });
        Ok(Self {
            host,
            started,
            issuer,
            grant,
            origin,
            client: reqwest::Client::new(),
            _tasks: tasks,
        })
    }

    async fn invoke(&self, request_id: &str, actor_id: &str) -> Result<Value> {
        let actor = ActorKey {
            project_id: "default".into(),
            actor_name: "Counter".into(),
            actor_id: actor_id.into(),
        };
        let token = self.issuer.issue_invocation_target(
            &actor,
            self.host.id(),
            "00000000-0000-4000-8000-000000000001",
            "revision-1",
            "us-east",
            1,
            self.grant.clone(),
            "http://10.1.2.3:7101",
        )?;
        Ok(self.client.post(format!("{}/v1/projects/default/actors/Counter/{actor_id}/invoke", self.origin))
            .bearer_auth(token.token)
            .json(&json!({"requestId": request_id, "ownerEpoch": 1, "method": "increment", "args": []}))
            .send().await?.error_for_status()?.json().await?)
    }
}