fix: project native repository runs #45

Manually merged
day01 merged 1 commits from fix/0.6-project-native-runs into develop 2026-08-30 13:58:41 +00:00
9 changed files with 304 additions and 8 deletions
Showing only changes of commit e9bc25159c - Show all commits
+6 -1
View File
@@ -1,381 +1,386 @@
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;

use clap::{Parser, Subcommand, ValueEnum};
use syncode_control::action_delivery::router as action_delivery_router;
use syncode_control::action_oci::PinnedOciResolver;
use syncode_control::action_repository::NativeActionRepository;
use syncode_control::action_store::FileActionStore;
use syncode_control::actions::ActionResolver;
use syncode_control::actions_read::ActionsRead;
use syncode_control::actions_read_identity::IdentityActionsAuthorization;
use syncode_control::admin::{Admin, router as admin_router};
use syncode_control::check_events::{CheckEventPublisher, CheckEventPublisherConfig};
use syncode_control::checks::Checks;
use syncode_control::maintenance;
use syncode_control::projection;
use syncode_control::repository::{RepositoryContents, RepositorySecrets};
use syncode_control::repository_grpc::NativeRepositoryContents;
use syncode_control::repository_sources::RepositorySources;
use syncode_control::secrets::RuntimeSecrets;
use syncode_control::token::EnrolmentScope;
use syncode_control::webhook::{Intake, router};
use syncode_control_node::{
ArtifactTokenAuthority, CapabilityAuthority, GeneratedActionsReadServer, GeneratedChecksServer,
GeneratedSecretsServer, GeneratedServer, NodeSessionServer, ProjectionClient,
};
use syncode_control_nodes::{Nodes, Scope};
use syncode_control_runs::{
AuditConfiguration, AuditControlMode, AuditEvent, AuditPayload, NodeId, Runs, SchedulerPolicy,
};
use syncode_control_store::Postgres;
use tokio::net::TcpListener;
use tonic::transport::Server;
use url::Url;

#[derive(Debug, Parser)]
#[command(name = "syncode-control", version, about)]
struct Arguments {
#[command(subcommand)]
command: Option<Command>,

/// Where nodes open their session.
#[arg(long, default_value = "127.0.0.1:8090")]
listen: SocketAddr,

/// Where the native repository event feed delivers events.
#[arg(long, default_value = "127.0.0.1:8091")]
listen_events: SocketAddr,

/// The repository service this control plane reads workflows from.
#[arg(long, env = "SYNCODE_REPOSITORY_SOURCE_URL")]
repository_source: Url,

/// A token allowed to read repository contents.
#[arg(long, env = "SYNCODE_REPOSITORY_SOURCE_TOKEN", hide_env_values = true)]
repository_source_token: String,

/// Where native repositories are read over gRPC.
#[arg(long, env = "SYNCODE_REPOSITORY_GRPC_URL")]
repository_grpc: String,

#[arg(long, env = "SYNCODE_IDENTITY_GRPC_URL")]
identity_grpc: String,

#[arg(long, env = "SYNCODE_IDENTITY_SHARED_SECRET", hide_env_values = true)]
identity_shared_secret: String,

#[arg(long, env = "SYNCODE_ACTION_MIRROR_URL")]
action_mirror_url: Url,

#[arg(
long,
env = "SYNCODE_ACTION_ALLOWLIST",
value_delimiter = ',',
num_args = 1..
)]
action_allowlist: Vec<Url>,

#[arg(
long,
env = "SYNCODE_ACTION_OCI_REGISTRIES",
value_delimiter = ',',
num_args = 1..
)]
action_oci_registries: Vec<String>,

#[arg(long, env = "SYNCODE_ACTION_STORE")]
action_store: PathBuf,

#[arg(long, env = "SYNCODE_ACTION_ARTIFACT_PUBLIC_URL")]
action_artifact_public_url: Url,

#[arg(long, env = "SYNCODE_SECRET_SOURCE_URL")]
secret_source: Url,

#[arg(long, env = "SYNCODE_SECRET_SOURCE_TOKEN", hide_env_values = true)]
secret_source_token: String,

#[arg(long, env = "SYNCODE_CAPABILITY_SIGNING_KEY", hide_env_values = true)]
capability_signing_key: String,

#[arg(long, env = "SYNCODE_ARTIFACT_SIGNING_KEY", hide_env_values = true)]
artifact_signing_key: String,

#[arg(long, env = "SYNCODE_PROJECTION_URL")]
projection_url: Url,

#[arg(long, env = "SYNCODE_PROJECTION_TOKEN", hide_env_values = true)]
projection_token: String,

#[arg(long, env = "SYNCODE_PROJECTION_MIN_RUN_NUMBER")]
projection_min_run_number: u64,

/// The secret used to sign the native event feed.
#[arg(long, env = "SYNCODE_EVENT_FEED_SECRET", hide_env_values = true)]
event_feed_secret: String,

#[arg(long, env = "SYNCODE_COLLAB_EVENT_URL")]
collaboration_event_url: Url,

#[arg(long, env = "SYNCODE_COLLAB_EVENT_SECRET", hide_env_values = true)]
collaboration_event_secret: String,

#[arg(long, env = "SYNCODE_CONTROL_SOURCE_NODE_ID")]
source_node_id: uuid::Uuid,

#[arg(
long,
env = "SYNCODE_CHECK_EVENT_INTERVAL_SECONDS",
default_value_t = 1
)]
check_event_interval_seconds: u64,

#[arg(long, env = "SYNCODE_CHECK_EVENT_BATCH", default_value_t = 100)]
check_event_batch: i64,

/// Bearer token protecting the operational API.
#[arg(long, env = "SYNCODE_CONTROL_ADMIN_TOKEN", hide_env_values = true)]
admin_token: Option<String>,

/// Where the run log is kept.
#[arg(long, env = "SYNCODE_DATABASE_URL", hide_env_values = true)]
database: String,

#[arg(long, default_value_t = 8)]
database_connections: u32,

#[arg(long, env = "SYNCODE_ORGANIZATION_CONCURRENCY", default_value_t = 100)]
organization_concurrency: u32,

#[arg(long, env = "SYNCODE_REPOSITORY_CONCURRENCY", default_value_t = 20)]
repository_concurrency: u32,

#[arg(long, env = "SYNCODE_PRINCIPAL_CONCURRENCY", default_value_t = 20)]
principal_concurrency: u32,

#[arg(
long,
env = "SYNCODE_ORGANIZATION_QUEUE_QUOTA",
default_value_t = 10_000
)]
organization_queue_quota: u32,

#[arg(long, env = "SYNCODE_PRINCIPAL_QUEUE_QUOTA", default_value_t = 1_000)]
principal_queue_quota: u32,

#[arg(long, env = "SYNCODE_AUDIT_RETENTION_DAYS", default_value_t = 90)]
audit_retention_days: u32,

/// Whether compiled shadow runs may be assigned to nodes.
#[arg(long, value_enum, default_value_t = Mode::Shadow)]
mode: Mode,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum Mode {
Shadow,
Active,
}

#[derive(Debug, Subcommand)]
enum Command {
/// Issue an enrolment token a node can spend for an identity.
///
/// The token is stored before it is printed, so one that reaches an
/// operator is one the control plane will honour.
IssueToken {
/// How far the token reaches: `instance`, `organisation:<name>` or
/// `repository:<owner>/<name>`.
#[arg(long, default_value = "instance")]
scope: String,
},
/// Revoke a node identity immediately.
RevokeNode {
/// The node UUID printed at enrolment.
node: String,
},
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let arguments = Arguments::parse();
// The log is read before anything is served: a node that reconnects has to
// meet the run it was holding, not a control plane that has forgotten it.
let store = Postgres::connect(&arguments.database, arguments.database_connections).await?;
let policy = SchedulerPolicy::new(
arguments.organization_concurrency,
arguments.repository_concurrency,
arguments.principal_concurrency,
arguments.organization_queue_quota,
arguments.principal_queue_quota,
);
let runs = Runs::restored_with_policy(store.clone(), policy).await?;
let nodes = Nodes::restored(store.clone()).await?;

if let Some(command) = arguments.command {
match command {
Command::IssueToken { scope } => {
let scope: Scope = scope.parse::<EnrolmentScope>()?.into();
let token = nodes.issue_token(scope).await?;
println!("{}", token.secret().expose());
}
Command::RevokeNode { node } => {
nodes.revoke(node.parse::<NodeId>()?).await?;
}
}
return Ok(());
}

store
.record_audit(AuditEvent::new("control.configuration", "applied").after(
AuditPayload::Configuration(AuditConfiguration {
mode: match arguments.mode {
Mode::Shadow => AuditControlMode::Shadow,
Mode::Active => AuditControlMode::Active,
},
organization_concurrency: arguments.organization_concurrency,
repository_concurrency: arguments.repository_concurrency,
principal_concurrency: arguments.principal_concurrency,
organization_queue_quota: arguments.organization_queue_quota,
principal_queue_quota: arguments.principal_queue_quota,
audit_retention_days: arguments.audit_retention_days,
projection_min_run_number: arguments.projection_min_run_number,
}),
))
.await?;
tokio::spawn(maintenance::sweep(runs.clone(), nodes.clone()));
tokio::spawn(maintenance::retain_audit(
store.clone(),
arguments.audit_retention_days,
));

let action_mirror = arguments
.action_mirror_url
.to_string()
.parse::<syncode_workflow::RepositoryUrl>()?;
let action_allowlist = arguments
.action_allowlist
.into_iter()
.map(|repository| {
repository
.to_string()
.parse::<syncode_workflow::RepositoryUrl>()
})
.collect::<Result<Vec<_>, _>>()?;
let action_store = FileActionStore::new(arguments.action_store);
let action_repository = NativeActionRepository::connect(
arguments.repository_grpc.clone(),
arguments.identity_grpc.clone(),
arguments.identity_shared_secret.clone(),
)
.await?;
let action_resolver = ActionResolver::new(
action_repository,
action_store.clone(),
PinnedOciResolver::new(arguments.action_oci_registries)?,
action_mirror,
action_allowlist,
);
let native_repository =
NativeRepositoryContents::connect(arguments.repository_grpc.clone()).await?;
let repository_sources = RepositorySources::new(
native_repository,
RepositoryContents::new(
arguments.repository_source,
arguments.repository_source_token,
),
);
let intake = Arc::new(Intake::new(
repository_sources,
runs.clone(),
action_resolver,
arguments.event_feed_secret,
));
let admin_token = arguments
.admin_token
.filter(|token| !token.is_empty())
.ok_or("SYNCODE_CONTROL_ADMIN_TOKEN is required while serving")?;
let artifact_authority = ArtifactTokenAuthority::new(arguments.artifact_signing_key)?;
let http = router(intake)
.merge(admin_router(Arc::new(
Admin::new(runs.clone(), nodes.clone(), admin_token).with_operations(store.clone()),
)))
.merge(action_delivery_router(
action_store,
artifact_authority.clone(),
runs.clone(),
));
let events = TcpListener::bind(arguments.listen_events).await?;
let check_events = CheckEventPublisher::new(
store.clone(),
CheckEventPublisherConfig {
endpoint: arguments.collaboration_event_url.as_str(),
secret: arguments.collaboration_event_secret,
source_node_id: arguments.source_node_id,
interval: std::time::Duration::from_secs(arguments.check_event_interval_seconds),
batch: arguments.check_event_batch,
},
)?;

let authority = CapabilityAuthority::new(arguments.capability_signing_key)?;
let projection = ProjectionClient::new(arguments.projection_url, arguments.projection_token)?;
let secret_service = RuntimeSecrets::new(
runs.clone(),
nodes.clone(),
RepositorySecrets::new(arguments.secret_source, arguments.secret_source_token),
authority.clone(),
);
let actions_read = ActionsRead::new(
runs.clone(),
IdentityActionsAuthorization::connect(
arguments.identity_grpc,
arguments.identity_shared_secret,
)
.await?,
);

// Both ends of the control plane run for as long as the other does: without
// events there is nothing to assign, and without sessions there is nobody to
// assign it to. Whichever stops first takes the process down with it.
let node_service = match arguments.mode {
Mode::Shadow => NodeSessionServer::shadow(
runs.clone(),
nodes,
authority,
artifact_authority,
arguments.action_artifact_public_url,
projection.clone(),
),
Mode::Active => NodeSessionServer::new(
runs.clone(),
nodes,
authority,
artifact_authority,
arguments.action_artifact_public_url,
projection.clone(),
),
};
tokio::select! {
served = axum::serve(events, http).into_future() => served?,
served = projection::serve(runs.clone(), projection, arguments.projection_min_run_number) => served?,
published = check_events.run() => published?,
served = Server::builder()
.add_service(GeneratedServer::new(node_service))
.add_service(GeneratedSecretsServer::new(secret_service))
.add_service(GeneratedChecksServer::new(Checks::new(runs.clone())))
.add_service(GeneratedActionsReadServer::new(actions_read))
.serve_with_shutdown(arguments.listen, shutdown()) => served?,
}

Ok(())
}

async fn shutdown() {
if let Err(error) = tokio::signal::ctrl_c().await {
eprintln!("cannot listen for shutdown, keeping the service running: {error}");
std::future::pending::<()>().await;
}
}
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;

use clap::{Parser, Subcommand, ValueEnum};
use syncode_control::action_delivery::router as action_delivery_router;
use syncode_control::action_oci::PinnedOciResolver;
use syncode_control::action_repository::NativeActionRepository;
use syncode_control::action_store::FileActionStore;
use syncode_control::actions::ActionResolver;
use syncode_control::actions_read::ActionsRead;
use syncode_control::actions_read_identity::IdentityActionsAuthorization;
use syncode_control::admin::{Admin, router as admin_router};
use syncode_control::check_events::{CheckEventPublisher, CheckEventPublisherConfig};
use syncode_control::checks::Checks;
use syncode_control::maintenance;
use syncode_control::projection;
use syncode_control::repository::{RepositoryContents, RepositorySecrets};
use syncode_control::repository_grpc::NativeRepositoryContents;
use syncode_control::repository_sources::RepositorySources;
use syncode_control::secrets::RuntimeSecrets;
use syncode_control::token::EnrolmentScope;
use syncode_control::webhook::{Intake, router};
use syncode_control_node::{
ArtifactTokenAuthority, CapabilityAuthority, GeneratedActionsReadServer, GeneratedChecksServer,
GeneratedSecretsServer, GeneratedServer, NodeSessionServer, ProjectionClient,
};
use syncode_control_nodes::{Nodes, Scope};
use syncode_control_runs::{
AuditConfiguration, AuditControlMode, AuditEvent, AuditPayload, NodeId, Runs, SchedulerPolicy,
};
use syncode_control_store::Postgres;
use tokio::net::TcpListener;
use tonic::transport::Server;
use url::Url;

#[derive(Debug, Parser)]
#[command(name = "syncode-control", version, about)]
struct Arguments {
#[command(subcommand)]
command: Option<Command>,

/// Where nodes open their session.
#[arg(long, default_value = "127.0.0.1:8090")]
listen: SocketAddr,

/// Where the native repository event feed delivers events.
#[arg(long, default_value = "127.0.0.1:8091")]
listen_events: SocketAddr,

/// The repository service this control plane reads workflows from.
#[arg(long, env = "SYNCODE_REPOSITORY_SOURCE_URL")]
repository_source: Url,

/// A token allowed to read repository contents.
#[arg(long, env = "SYNCODE_REPOSITORY_SOURCE_TOKEN", hide_env_values = true)]
repository_source_token: String,

/// Where native repositories are read over gRPC.
#[arg(long, env = "SYNCODE_REPOSITORY_GRPC_URL")]
repository_grpc: String,

#[arg(long, env = "SYNCODE_IDENTITY_GRPC_URL")]
identity_grpc: String,

#[arg(long, env = "SYNCODE_IDENTITY_SHARED_SECRET", hide_env_values = true)]
identity_shared_secret: String,

#[arg(long, env = "SYNCODE_ACTION_MIRROR_URL")]
action_mirror_url: Url,

#[arg(
long,
env = "SYNCODE_ACTION_ALLOWLIST",
value_delimiter = ',',
num_args = 1..
)]
action_allowlist: Vec<Url>,

#[arg(
long,
env = "SYNCODE_ACTION_OCI_REGISTRIES",
value_delimiter = ',',
num_args = 1..
)]
action_oci_registries: Vec<String>,

#[arg(long, env = "SYNCODE_ACTION_STORE")]
action_store: PathBuf,

#[arg(long, env = "SYNCODE_ACTION_ARTIFACT_PUBLIC_URL")]
action_artifact_public_url: Url,

#[arg(long, env = "SYNCODE_SECRET_SOURCE_URL")]
secret_source: Url,

#[arg(long, env = "SYNCODE_SECRET_SOURCE_TOKEN", hide_env_values = true)]
secret_source_token: String,

#[arg(long, env = "SYNCODE_CAPABILITY_SIGNING_KEY", hide_env_values = true)]
capability_signing_key: String,

#[arg(long, env = "SYNCODE_ARTIFACT_SIGNING_KEY", hide_env_values = true)]
artifact_signing_key: String,

#[arg(long, env = "SYNCODE_PROJECTION_URL")]
projection_url: Url,

#[arg(long, env = "SYNCODE_PROJECTION_TOKEN", hide_env_values = true)]
projection_token: String,

#[arg(long, env = "SYNCODE_PROJECTION_MIN_RUN_NUMBER")]
projection_min_run_number: u64,

/// The secret used to sign the native event feed.
#[arg(long, env = "SYNCODE_EVENT_FEED_SECRET", hide_env_values = true)]
event_feed_secret: String,

#[arg(long, env = "SYNCODE_COLLAB_EVENT_URL")]
collaboration_event_url: Url,

#[arg(long, env = "SYNCODE_COLLAB_EVENT_SECRET", hide_env_values = true)]
collaboration_event_secret: String,

#[arg(long, env = "SYNCODE_CONTROL_SOURCE_NODE_ID")]
source_node_id: uuid::Uuid,

#[arg(
long,
env = "SYNCODE_CHECK_EVENT_INTERVAL_SECONDS",
default_value_t = 1
)]
check_event_interval_seconds: u64,

#[arg(long, env = "SYNCODE_CHECK_EVENT_BATCH", default_value_t = 100)]
check_event_batch: i64,

/// Bearer token protecting the operational API.
#[arg(long, env = "SYNCODE_CONTROL_ADMIN_TOKEN", hide_env_values = true)]
admin_token: Option<String>,

/// Where the run log is kept.
#[arg(long, env = "SYNCODE_DATABASE_URL", hide_env_values = true)]
database: String,

#[arg(long, default_value_t = 8)]
database_connections: u32,

#[arg(long, env = "SYNCODE_ORGANIZATION_CONCURRENCY", default_value_t = 100)]
organization_concurrency: u32,

#[arg(long, env = "SYNCODE_REPOSITORY_CONCURRENCY", default_value_t = 20)]
repository_concurrency: u32,

#[arg(long, env = "SYNCODE_PRINCIPAL_CONCURRENCY", default_value_t = 20)]
principal_concurrency: u32,

#[arg(
long,
env = "SYNCODE_ORGANIZATION_QUEUE_QUOTA",
default_value_t = 10_000
)]
organization_queue_quota: u32,

#[arg(long, env = "SYNCODE_PRINCIPAL_QUEUE_QUOTA", default_value_t = 1_000)]
principal_queue_quota: u32,

#[arg(long, env = "SYNCODE_AUDIT_RETENTION_DAYS", default_value_t = 90)]
audit_retention_days: u32,

/// Whether compiled shadow runs may be assigned to nodes.
#[arg(long, value_enum, default_value_t = Mode::Shadow)]
mode: Mode,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum Mode {
Shadow,
Active,
}

#[derive(Debug, Subcommand)]
enum Command {
/// Issue an enrolment token a node can spend for an identity.
///
/// The token is stored before it is printed, so one that reaches an
/// operator is one the control plane will honour.
IssueToken {
/// How far the token reaches: `instance`, `organisation:<name>` or
/// `repository:<owner>/<name>`.
#[arg(long, default_value = "instance")]
scope: String,
},
/// Revoke a node identity immediately.
RevokeNode {
/// The node UUID printed at enrolment.
node: String,
},
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let arguments = Arguments::parse();
// The log is read before anything is served: a node that reconnects has to
// meet the run it was holding, not a control plane that has forgotten it.
let store = Postgres::connect(&arguments.database, arguments.database_connections).await?;
let policy = SchedulerPolicy::new(
arguments.organization_concurrency,
arguments.repository_concurrency,
arguments.principal_concurrency,
arguments.organization_queue_quota,
arguments.principal_queue_quota,
);
let runs = Runs::restored_with_policy(store.clone(), policy).await?;
let nodes = Nodes::restored(store.clone()).await?;

if let Some(command) = arguments.command {
match command {
Command::IssueToken { scope } => {
let scope: Scope = scope.parse::<EnrolmentScope>()?.into();
let token = nodes.issue_token(scope).await?;
println!("{}", token.secret().expose());
}
Command::RevokeNode { node } => {
nodes.revoke(node.parse::<NodeId>()?).await?;
}
}
return Ok(());
}

store
.record_audit(AuditEvent::new("control.configuration", "applied").after(
AuditPayload::Configuration(AuditConfiguration {
mode: match arguments.mode {
Mode::Shadow => AuditControlMode::Shadow,
Mode::Active => AuditControlMode::Active,
},
organization_concurrency: arguments.organization_concurrency,
repository_concurrency: arguments.repository_concurrency,
principal_concurrency: arguments.principal_concurrency,
organization_queue_quota: arguments.organization_queue_quota,
principal_queue_quota: arguments.principal_queue_quota,
audit_retention_days: arguments.audit_retention_days,
projection_min_run_number: arguments.projection_min_run_number,
}),
))
.await?;
tokio::spawn(maintenance::sweep(runs.clone(), nodes.clone()));
tokio::spawn(maintenance::retain_audit(
store.clone(),
arguments.audit_retention_days,
));

let action_mirror = arguments
.action_mirror_url
.to_string()
.parse::<syncode_workflow::RepositoryUrl>()?;
let action_allowlist = arguments
.action_allowlist
.into_iter()
.map(|repository| {
repository
.to_string()
.parse::<syncode_workflow::RepositoryUrl>()
})
.collect::<Result<Vec<_>, _>>()?;
let action_store = FileActionStore::new(arguments.action_store);
let action_repository = NativeActionRepository::connect(
arguments.repository_grpc.clone(),
arguments.identity_grpc.clone(),
arguments.identity_shared_secret.clone(),
)
.await?;
let action_resolver = ActionResolver::new(
action_repository,
action_store.clone(),
PinnedOciResolver::new(arguments.action_oci_registries)?,
action_mirror,
action_allowlist,
);
let native_repository =
NativeRepositoryContents::connect(arguments.repository_grpc.clone()).await?;
let repository_sources = RepositorySources::new(
native_repository,
RepositoryContents::new(
arguments.repository_source,
arguments.repository_source_token,
),
);
let intake = Arc::new(Intake::new(
repository_sources,
runs.clone(),
action_resolver,
arguments.event_feed_secret,
));
let admin_token = arguments
.admin_token
.filter(|token| !token.is_empty())
.ok_or("SYNCODE_CONTROL_ADMIN_TOKEN is required while serving")?;
let artifact_authority = ArtifactTokenAuthority::new(arguments.artifact_signing_key)?;
let http = router(intake)
.merge(admin_router(Arc::new(
Admin::new(runs.clone(), nodes.clone(), admin_token).with_operations(store.clone()),
)))
.merge(action_delivery_router(
action_store,
artifact_authority.clone(),
runs.clone(),
));
let events = TcpListener::bind(arguments.listen_events).await?;
let check_events = CheckEventPublisher::new(
store.clone(),
CheckEventPublisherConfig {
endpoint: arguments.collaboration_event_url.as_str(),
secret: arguments.collaboration_event_secret,
source_node_id: arguments.source_node_id,
interval: std::time::Duration::from_secs(arguments.check_event_interval_seconds),
batch: arguments.check_event_batch,
},
)?;

let authority = CapabilityAuthority::new(arguments.capability_signing_key)?;
let projection = ProjectionClient::new(
arguments.projection_url,
arguments.projection_token,
arguments.identity_grpc.clone(),
arguments.identity_shared_secret.clone(),
)?;
let secret_service = RuntimeSecrets::new(
runs.clone(),
nodes.clone(),
RepositorySecrets::new(arguments.secret_source, arguments.secret_source_token),
authority.clone(),
);
let actions_read = ActionsRead::new(
runs.clone(),
IdentityActionsAuthorization::connect(
arguments.identity_grpc,
arguments.identity_shared_secret,
)
.await?,
);

// Both ends of the control plane run for as long as the other does: without
// events there is nothing to assign, and without sessions there is nobody to
// assign it to. Whichever stops first takes the process down with it.
let node_service = match arguments.mode {
Mode::Shadow => NodeSessionServer::shadow(
runs.clone(),
nodes,
authority,
artifact_authority,
arguments.action_artifact_public_url,
projection.clone(),
),
Mode::Active => NodeSessionServer::new(
runs.clone(),
nodes,
authority,
artifact_authority,
arguments.action_artifact_public_url,
projection.clone(),
),
};
tokio::select! {
served = axum::serve(events, http).into_future() => served?,
served = projection::serve(runs.clone(), projection, arguments.projection_min_run_number) => served?,
published = check_events.run() => published?,
served = Server::builder()
.add_service(GeneratedServer::new(node_service))
.add_service(GeneratedSecretsServer::new(secret_service))
.add_service(GeneratedChecksServer::new(Checks::new(runs.clone())))
.add_service(GeneratedActionsReadServer::new(actions_read))
.serve_with_shutdown(arguments.listen, shutdown()) => served?,
}

Ok(())
}

async fn shutdown() {
if let Err(error) = tokio::signal::ctrl_c().await {
eprintln!("cannot listen for shutdown, keeping the service running: {error}");
std::future::pending::<()>().await;
}
}
+10 -2
View File
@@ -1,121 +1,129 @@
#![allow(clippy::expect_used)]

#[path = "support/repository.rs"]
#[allow(dead_code)]
mod repository;

use std::error::Error;

use syncode_control::action_repository::NativeActionRepository;
use syncode_control::actions::ActionRepositoryPort;
use syncode_control_node::identity_wire::identity_server::{Identity, IdentityServer};
use syncode_control_node::identity_wire::{
CheckCapabilityRequest, CheckCapabilityResponse, ResolveRepositoryRequest,
ResolveRepositoryResponse, ValidateSessionRequest, ValidateSessionResponse,
};
use tokio::net::TcpListener;
use tokio_stream::wrappers::TcpListenerStream;
use tonic::transport::Server;
use tonic::{Request, Response, Status};

type TestResult<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;

#[derive(Default)]
struct FixtureIdentity;

#[tonic::async_trait]
impl Identity for FixtureIdentity {
async fn validate_session(
&self,
_request: Request<ValidateSessionRequest>,
) -> Result<Response<ValidateSessionResponse>, Status> {
Err(Status::unimplemented("validate_session"))
}

async fn check_capability(
&self,
_request: Request<CheckCapabilityRequest>,
) -> Result<Response<CheckCapabilityResponse>, Status> {
Err(Status::unimplemented("check_capability"))
}

async fn resolve_repository(
&self,
request: Request<ResolveRepositoryRequest>,
) -> Result<Response<ResolveRepositoryResponse>, Status> {
if request
.metadata()
.get("authorization")
.and_then(|value| value.to_str().ok())
!= Some("Bearer shared-secret")
{
return Err(Status::unauthenticated("missing authorization"));
}
let request = request.into_inner();
if request.owner != "actions" || request.name != "checkout" {
return Err(Status::not_found("repository"));
}
Ok(Response::new(ResolveRepositoryResponse {
repository_id: repository::REPOSITORY.to_owned(),
}))
}
}

async fn source() -> TestResult<NativeActionRepository> {
let repository_listener = TcpListener::bind("127.0.0.1:0").await?;
let repository_endpoint = format!("http://{}", repository_listener.local_addr()?);
tokio::spawn(async move {
let _ = Server::builder()
.add_service(repository::service())
.serve_with_incoming(TcpListenerStream::new(repository_listener))
.await;
});

let identity_listener = TcpListener::bind("127.0.0.1:0").await?;
let identity_endpoint = format!("http://{}", identity_listener.local_addr()?);
tokio::spawn(async move {
let _ = Server::builder()
.add_service(IdentityServer::new(FixtureIdentity))
.serve_with_incoming(TcpListenerStream::new(identity_listener))
.await;
});

Ok(NativeActionRepository::connect(
repository_endpoint,
identity_endpoint,
"shared-secret".to_owned(),
)
.await?)
}

#[tokio::test]
async fn resolves_coordinates_and_fetches_an_immutable_native_archive() -> TestResult {
let source = source().await?;
let snapshot = source
.fetch(
"https://dev.syncode.sh/actions/checkout".parse()?,
"v4".to_owned(),
)
.await?;

assert_eq!(snapshot.commit.as_ref(), repository::COMMIT);
let paths = tar::Archive::new(snapshot.archive.as_slice())
.entries()?
.map(|entry| entry.and_then(|entry| entry.path().map(|path| path.into_owned())))
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(paths, [std::path::PathBuf::from("source/action.yml")]);
Ok(())
}

#[tokio::test]
async fn accepts_a_native_repository_id_without_coordinate_resolution() -> TestResult {
let source = source().await?;
let snapshot = source
.fetch(
format!("https://dev.syncode.sh/{}", repository::REPOSITORY).parse()?,
repository::COMMIT.to_owned(),
)
.await?;
assert_eq!(snapshot.commit.as_ref(), repository::COMMIT);
Ok(())
}
#![allow(clippy::expect_used)]

#[path = "support/repository.rs"]
#[allow(dead_code)]
mod repository;

use std::error::Error;

use syncode_control::action_repository::NativeActionRepository;
use syncode_control::actions::ActionRepositoryPort;
use syncode_control_node::identity_wire::identity_server::{Identity, IdentityServer};
use syncode_control_node::identity_wire::{
CheckCapabilityRequest, CheckCapabilityResponse, GetRepositoryCoordinatesRequest,
GetRepositoryCoordinatesResponse, ResolveRepositoryRequest, ResolveRepositoryResponse,
ValidateSessionRequest, ValidateSessionResponse,
};
use tokio::net::TcpListener;
use tokio_stream::wrappers::TcpListenerStream;
use tonic::transport::Server;
use tonic::{Request, Response, Status};

type TestResult<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;

#[derive(Default)]
struct FixtureIdentity;

#[tonic::async_trait]
impl Identity for FixtureIdentity {
async fn get_repository_coordinates(
&self,
_request: Request<GetRepositoryCoordinatesRequest>,
) -> Result<Response<GetRepositoryCoordinatesResponse>, Status> {
Err(Status::unimplemented("get_repository_coordinates"))
}

async fn validate_session(
&self,
_request: Request<ValidateSessionRequest>,
) -> Result<Response<ValidateSessionResponse>, Status> {
Err(Status::unimplemented("validate_session"))
}

async fn check_capability(
&self,
_request: Request<CheckCapabilityRequest>,
) -> Result<Response<CheckCapabilityResponse>, Status> {
Err(Status::unimplemented("check_capability"))
}

async fn resolve_repository(
&self,
request: Request<ResolveRepositoryRequest>,
) -> Result<Response<ResolveRepositoryResponse>, Status> {
if request
.metadata()
.get("authorization")
.and_then(|value| value.to_str().ok())
!= Some("Bearer shared-secret")
{
return Err(Status::unauthenticated("missing authorization"));
}
let request = request.into_inner();
if request.owner != "actions" || request.name != "checkout" {
return Err(Status::not_found("repository"));
}
Ok(Response::new(ResolveRepositoryResponse {
repository_id: repository::REPOSITORY.to_owned(),
}))
}
}

async fn source() -> TestResult<NativeActionRepository> {
let repository_listener = TcpListener::bind("127.0.0.1:0").await?;
let repository_endpoint = format!("http://{}", repository_listener.local_addr()?);
tokio::spawn(async move {
let _ = Server::builder()
.add_service(repository::service())
.serve_with_incoming(TcpListenerStream::new(repository_listener))
.await;
});

let identity_listener = TcpListener::bind("127.0.0.1:0").await?;
let identity_endpoint = format!("http://{}", identity_listener.local_addr()?);
tokio::spawn(async move {
let _ = Server::builder()
.add_service(IdentityServer::new(FixtureIdentity))
.serve_with_incoming(TcpListenerStream::new(identity_listener))
.await;
});

Ok(NativeActionRepository::connect(
repository_endpoint,
identity_endpoint,
"shared-secret".to_owned(),
)
.await?)
}

#[tokio::test]
async fn resolves_coordinates_and_fetches_an_immutable_native_archive() -> TestResult {
let source = source().await?;
let snapshot = source
.fetch(
"https://dev.syncode.sh/actions/checkout".parse()?,
"v4".to_owned(),
)
.await?;

assert_eq!(snapshot.commit.as_ref(), repository::COMMIT);
let paths = tar::Archive::new(snapshot.archive.as_slice())
.entries()?
.map(|entry| entry.and_then(|entry| entry.path().map(|path| path.into_owned())))
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(paths, [std::path::PathBuf::from("source/action.yml")]);
Ok(())
}

#[tokio::test]
async fn accepts_a_native_repository_id_without_coordinate_resolution() -> TestResult {
let source = source().await?;
let snapshot = source
.fetch(
format!("https://dev.syncode.sh/{}", repository::REPOSITORY).parse()?,
repository::COMMIT.to_owned(),
)
.await?;
assert_eq!(snapshot.commit.as_ref(), repository::COMMIT);
Ok(())
}
File diff suppressed because it is too large Load Diff
+57 -2
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -1,60 +1,70 @@
syntax = "proto3";

package syncode.identity.v1;

service Identity {
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
rpc CheckCapability(CheckCapabilityRequest) returns (CheckCapabilityResponse);
rpc ResolveRepository(ResolveRepositoryRequest) returns (ResolveRepositoryResponse);
}

message ResolveRepositoryRequest {
string owner = 1;
string name = 2;
}

message ResolveRepositoryResponse {
string repository_id = 1;
}

enum PrincipalKind {
PRINCIPAL_KIND_UNSPECIFIED = 0;
PRINCIPAL_KIND_USER = 1;
PRINCIPAL_KIND_PLATFORM_AGENT = 2;
PRINCIPAL_KIND_LOCAL_AGENT = 3;
PRINCIPAL_KIND_ACCESS_TOKEN = 4;
}

enum ResourceKind {
RESOURCE_KIND_UNSPECIFIED = 0;
RESOURCE_KIND_ORGANIZATION = 1;
RESOURCE_KIND_REPOSITORY = 2;
RESOURCE_KIND_INSTANCE = 3;
}

message ValidateSessionRequest {
string session_token = 1;
}

message ValidateSessionResponse {
string principal_id = 1;
PrincipalKind principal_kind = 2;
int64 expires_at_unix = 3;
string owner_user_id = 4;
repeated string capabilities = 5;
string audience = 6;
ResourceKind resource_kind = 7;
string resource_id = 8;
}

message CheckCapabilityRequest {
string principal_id = 1;
PrincipalKind principal_kind = 2;
ResourceKind resource_kind = 3;
string resource_id = 4;
string capability = 5;
}

message CheckCapabilityResponse {
bool allowed = 1;
}
syntax = "proto3";

package syncode.identity.v1;

service Identity {
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
rpc CheckCapability(CheckCapabilityRequest) returns (CheckCapabilityResponse);
rpc ResolveRepository(ResolveRepositoryRequest) returns (ResolveRepositoryResponse);
rpc GetRepositoryCoordinates(GetRepositoryCoordinatesRequest) returns (GetRepositoryCoordinatesResponse);
}

message ResolveRepositoryRequest {
string owner = 1;
string name = 2;
}

message ResolveRepositoryResponse {
string repository_id = 1;
}

message GetRepositoryCoordinatesRequest {
string repository_id = 1;
}

message GetRepositoryCoordinatesResponse {
string owner = 1;
string name = 2;
}

enum PrincipalKind {
PRINCIPAL_KIND_UNSPECIFIED = 0;
PRINCIPAL_KIND_USER = 1;
PRINCIPAL_KIND_PLATFORM_AGENT = 2;
PRINCIPAL_KIND_LOCAL_AGENT = 3;
PRINCIPAL_KIND_ACCESS_TOKEN = 4;
}

enum ResourceKind {
RESOURCE_KIND_UNSPECIFIED = 0;
RESOURCE_KIND_ORGANIZATION = 1;
RESOURCE_KIND_REPOSITORY = 2;
RESOURCE_KIND_INSTANCE = 3;
}

message ValidateSessionRequest {
string session_token = 1;
}

message ValidateSessionResponse {
string principal_id = 1;
PrincipalKind principal_kind = 2;
int64 expires_at_unix = 3;
string owner_user_id = 4;
repeated string capabilities = 5;
string audience = 6;
ResourceKind resource_kind = 7;
string resource_id = 8;
}

message CheckCapabilityRequest {
string principal_id = 1;
PrincipalKind principal_kind = 2;
ResourceKind resource_kind = 3;
string resource_id = 4;
string capability = 5;
}

message CheckCapabilityResponse {
bool allowed = 1;
}
+82 -2
View File
@@ -1,59 +1,139 @@
use syncode_control_runs::ProjectedRun;
use thiserror::Error;
use url::Url;

/// Pushes a run's projected state to the forge. Dispatch uses it to close the
/// gap between handing a node a token and the forge knowing what that token is
/// for; the periodic sweep in the binary uses it for everything after.
#[derive(Clone)]
pub struct ProjectionClient {
endpoint: Url,
token: String,
client: reqwest::Client,
}

#[derive(Debug, Error)]
pub enum ProjectionClientError {
#[error("cannot address the projection endpoint")]
Address,
}

#[derive(Debug, Error)]
pub enum ProjectionRequestError {
#[error(transparent)]
Transport(#[from] reqwest::Error),
#[error("HTTP {status}: {body}")]
Rejected {
status: reqwest::StatusCode,
body: String,
},
}

impl ProjectionClient {
pub fn new(base: Url, token: String) -> Result<Self, ProjectionClientError> {
let endpoint = base
.join("api/internal/actions/syncode/projection")
.map_err(|_| ProjectionClientError::Address)?;
Ok(Self {
endpoint,
token,
client: reqwest::Client::new(),
})
}

pub async fn send(&self, projection: &ProjectedRun) -> Result<(), ProjectionRequestError> {
let response = self
.client
.post(self.endpoint.clone())
.header("X-Gitea-Internal-Auth", format!("Bearer {}", self.token))
.json(projection)
.send()
.await?;
let status = response.status();
if status.is_success() {
return Ok(());
}
let body = response.text().await?;
Err(ProjectionRequestError::Rejected { status, body })
}
}
use syncode_control_runs::ProjectedRun;
use thiserror::Error;
use tonic::Request;
use tonic::metadata::{Ascii, MetadataValue};
use tonic::transport::{Channel, Endpoint};
use url::Url;
use uuid::Uuid;

use crate::identity_wire::GetRepositoryCoordinatesRequest;
use crate::identity_wire::identity_client::IdentityClient;

/// Pushes a run's projected state to the forge. Dispatch uses it to close the
/// gap between handing a node a token and the forge knowing what that token is
/// for; the periodic sweep in the binary uses it for everything after.
#[derive(Clone)]
pub struct ProjectionClient {
endpoint: Url,
token: String,
client: reqwest::Client,
identity: IdentityClient<Channel>,
authorization: MetadataValue<Ascii>,
}

#[derive(Debug, Error)]
pub enum ProjectionClientError {
#[error("cannot address the projection endpoint")]
Address,
#[error("cannot address the identity endpoint")]
IdentityAddress,
#[error("cannot authorize identity requests")]
IdentityAuthorization,
}

#[derive(Debug, Error)]
pub enum ProjectionRequestError {
#[error(transparent)]
Transport(#[from] reqwest::Error),
#[error("HTTP {status}: {body}")]
Rejected {
status: reqwest::StatusCode,
body: String,
},
#[error("identity rejected the repository projection: {0}")]
Identity(#[from] tonic::Status),
#[error("invalid repository projection origin {0:?}")]
InvalidRepository(String),
}

impl ProjectionClient {
pub fn new(
base: Url,
token: String,
identity_endpoint: String,
identity_shared_secret: String,
) -> Result<Self, ProjectionClientError> {
let endpoint = base
.join("api/internal/actions/syncode/projection")
.map_err(|_| ProjectionClientError::Address)?;
let identity = IdentityClient::new(
Endpoint::from_shared(identity_endpoint)
.map_err(|_| ProjectionClientError::IdentityAddress)?
.connect_lazy(),
);
let authorization = format!("Bearer {identity_shared_secret}")
.parse()
.map_err(|_| ProjectionClientError::IdentityAuthorization)?;
Ok(Self {
endpoint,
token,
client: reqwest::Client::new(),
identity,
authorization,
})
}

pub async fn send(&self, projection: &ProjectedRun) -> Result<(), ProjectionRequestError> {
let projection = self.for_legacy_projection(projection).await?;
let response = self
.client
.post(self.endpoint.clone())
.header("X-Gitea-Internal-Auth", format!("Bearer {}", self.token))
.json(&projection)
.send()
.await?;
let status = response.status();
if status.is_success() {
return Ok(());
}
let body = response.text().await?;
Err(ProjectionRequestError::Rejected { status, body })
}

async fn for_legacy_projection(
&self,
projection: &ProjectedRun,
) -> Result<ProjectedRun, ProjectionRequestError> {
let repository = projection.origin.repository();
if Uuid::parse_str(repository).is_ok() {
let mut request = Request::new(GetRepositoryCoordinatesRequest {
repository_id: repository.to_owned(),
});
request
.metadata_mut()
.insert("authorization", self.authorization.clone());
let coordinates = self
.identity
.clone()
.get_repository_coordinates(request)
.await?
.into_inner();
if coordinates.owner.is_empty()
|| coordinates.name.is_empty()
|| coordinates.owner.contains('/')
|| coordinates.name.contains('/')
{
return Err(ProjectionRequestError::InvalidRepository(format!(
"{}/{}",
coordinates.owner, coordinates.name
)));
}
let mut projected = projection.clone();
projected.origin = projected
.origin
.with_repository(format!("{}/{}", coordinates.owner, coordinates.name));
return Ok(projected);
}
let Some((owner, name)) = repository.split_once('/') else {
return Err(ProjectionRequestError::InvalidRepository(
repository.to_owned(),
));
};
if owner.is_empty() || name.is_empty() || name.contains('/') {
return Err(ProjectionRequestError::InvalidRepository(
repository.to_owned(),
));
}
Ok(projection.clone())
}
}
+6
View File
@@ -1,159 +1,165 @@
use std::fmt;

use serde::{Deserialize, Serialize};

/// Which run this is, counted from one across the control plane.
///
/// A plan carries no identity a person would recognise, and a job asked to
/// name itself has nothing else to say. The number is handed out when the run
/// is opened and written down with it, so a restart does not start counting
/// again.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub struct RunNumber(u64);

/// Where a run came from: the repository and commit its plan was compiled
/// from, the reference the event named, and what that event was.
///
/// A plan says what to do and nothing about what it is being done to, and a
/// node cannot ask a second question once it holds one. So the run remembers
/// where it came from and every assignment carries it.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Origin {
repository: String,
commit: String,
reference: String,
event: String,
/// The workflow file this run was compiled from, as the repository spells
/// it. One commit can answer one event with several workflows, so the file
/// is what tells their runs apart — and what any of them can be lined up
/// against on the legacy control that ran the same commit.
workflow: String,
#[serde(default)]
delivery: Option<String>,
#[serde(default)]
principal: Option<String>,
#[serde(default = "trusted")]
secrets_allowed: bool,
}

impl RunNumber {
/// The number the first run of an empty control plane is given. Counting
/// starts at one because zero is not a run anyone can refer to.
#[must_use]
pub const fn first() -> Self {
Self(1)
}

#[must_use]
pub const fn after(self) -> Self {
Self(self.0 + 1)
}

#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}

impl From<u64> for RunNumber {
fn from(value: u64) -> Self {
Self(value)
}
}

impl fmt::Display for RunNumber {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}

impl Origin {
#[must_use]
pub const fn new(
repository: String,
commit: String,
reference: String,
event: String,
workflow: String,
) -> Self {
Self {
repository,
commit,
reference,
event,
workflow,
delivery: None,
principal: None,
secrets_allowed: true,
}
}

#[must_use]
pub fn with_delivery(mut self, delivery: Option<String>) -> Self {
self.delivery = delivery;
self
}

#[must_use]
pub fn with_principal(mut self, principal: Option<String>) -> Self {
self.principal = principal.filter(|value| !value.is_empty());
self
}

#[must_use]
pub const fn with_secret_trust(mut self, allowed: bool) -> Self {
self.secrets_allowed = allowed;
self
}

#[must_use]
pub fn repository(&self) -> &str {
&self.repository
}

#[must_use]
pub fn commit(&self) -> &str {
&self.commit
}

#[must_use]
pub fn reference(&self) -> &str {
&self.reference
}

#[must_use]
pub fn event(&self) -> &str {
&self.event
}

#[must_use]
pub fn workflow(&self) -> &str {
&self.workflow
}

#[must_use]
pub fn delivery(&self) -> Option<&str> {
self.delivery.as_deref()
}

#[must_use]
pub fn principal(&self) -> Option<&str> {
self.principal.as_deref()
}

#[must_use]
pub const fn secrets_allowed(&self) -> bool {
self.secrets_allowed
}

#[must_use]
pub fn organization(&self) -> &str {
self.repository
.split_once('/')
.map_or(self.repository.as_str(), |(organization, _)| organization)
}
}

const fn trusted() -> bool {
true
}
use std::fmt;

use serde::{Deserialize, Serialize};

/// Which run this is, counted from one across the control plane.
///
/// A plan carries no identity a person would recognise, and a job asked to
/// name itself has nothing else to say. The number is handed out when the run
/// is opened and written down with it, so a restart does not start counting
/// again.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub struct RunNumber(u64);

/// Where a run came from: the repository and commit its plan was compiled
/// from, the reference the event named, and what that event was.
///
/// A plan says what to do and nothing about what it is being done to, and a
/// node cannot ask a second question once it holds one. So the run remembers
/// where it came from and every assignment carries it.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Origin {
repository: String,
commit: String,
reference: String,
event: String,
/// The workflow file this run was compiled from, as the repository spells
/// it. One commit can answer one event with several workflows, so the file
/// is what tells their runs apart — and what any of them can be lined up
/// against on the legacy control that ran the same commit.
workflow: String,
#[serde(default)]
delivery: Option<String>,
#[serde(default)]
principal: Option<String>,
#[serde(default = "trusted")]
secrets_allowed: bool,
}

impl RunNumber {
/// The number the first run of an empty control plane is given. Counting
/// starts at one because zero is not a run anyone can refer to.
#[must_use]
pub const fn first() -> Self {
Self(1)
}

#[must_use]
pub const fn after(self) -> Self {
Self(self.0 + 1)
}

#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}

impl From<u64> for RunNumber {
fn from(value: u64) -> Self {
Self(value)
}
}

impl fmt::Display for RunNumber {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}

impl Origin {
#[must_use]
pub const fn new(
repository: String,
commit: String,
reference: String,
event: String,
workflow: String,
) -> Self {
Self {
repository,
commit,
reference,
event,
workflow,
delivery: None,
principal: None,
secrets_allowed: true,
}
}

#[must_use]
pub fn with_delivery(mut self, delivery: Option<String>) -> Self {
self.delivery = delivery;
self
}

#[must_use]
pub fn with_repository(mut self, repository: String) -> Self {
self.repository = repository;
self
}

#[must_use]
pub fn with_principal(mut self, principal: Option<String>) -> Self {
self.principal = principal.filter(|value| !value.is_empty());
self
}

#[must_use]
pub const fn with_secret_trust(mut self, allowed: bool) -> Self {
self.secrets_allowed = allowed;
self
}

#[must_use]
pub fn repository(&self) -> &str {
&self.repository
}

#[must_use]
pub fn commit(&self) -> &str {
&self.commit
}

#[must_use]
pub fn reference(&self) -> &str {
&self.reference
}

#[must_use]
pub fn event(&self) -> &str {
&self.event
}

#[must_use]
pub fn workflow(&self) -> &str {
&self.workflow
}

#[must_use]
pub fn delivery(&self) -> Option<&str> {
self.delivery.as_deref()
}

#[must_use]
pub fn principal(&self) -> Option<&str> {
self.principal.as_deref()
}

#[must_use]
pub const fn secrets_allowed(&self) -> bool {
self.secrets_allowed
}

#[must_use]
pub fn organization(&self) -> &str {
self.repository
.split_once('/')
.map_or(self.repository.as_str(), |(organization, _)| organization)
}
}

const fn trusted() -> bool {
true
}
@@ -1,139 +1,141 @@
#![allow(clippy::expect_used, clippy::panic)]

use std::time::Duration;

use syncode_control_nodes::{Ephemeral, Lifecycle, Nodes, Scope};
use syncode_control_runs::{Forgotten, Runs};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;

use super::{NodeSessionServer, run_session};
use crate::wire::{
Capabilities, Capacity, Enrol, Heartbeat, Hello, NodeMessage, control_message, node_message,
};
use crate::{ArtifactTokenAuthority, CapabilityAuthority, ProjectionClient};

#[tokio::test]
async fn an_unreadable_client_releases_its_session() {
let runs = Runs::restored(Forgotten::default()).await.expect("runs");
let nodes = Nodes::restored(Ephemeral::default()).await.expect("nodes");
let token = nodes
.issue_token(Scope::Instance)
.await
.expect("enrolment token");
let server = NodeSessionServer::new(
runs,
nodes.clone(),
CapabilityAuthority::new("test-capability-key").expect("capability authority"),
ArtifactTokenAuthority::new("test-artifact-key").expect("artifact authority"),
"http://127.0.0.1:1/".parse().expect("artifact URL"),
ProjectionClient::new(
"http://127.0.0.1:1/".parse().expect("projection URL"),
String::new(),
)
.expect("projection client"),
);
let (input, input_receiver) = mpsc::channel(8);
let (output, mut output_receiver) = mpsc::channel(8);
let session = tokio::spawn(async move {
let mut inbound = ReceiverStream::new(input_receiver);
run_session(&mut inbound, &output, server).await
});

input
.send(Ok(message(
1,
node_message::Body::Enrol(Enrol {
token: token.secret().expose().to_owned(),
}),
)))
.await
.expect("enrol");
let enrolled = output_receiver
.recv()
.await
.expect("enrolment response")
.expect("enrolment status");
let control_message::Body::Enrolled(enrolled) = enrolled.body.expect("enrolment body") else {
panic!("expected enrolment");
};
let node = enrolled.node.parse().expect("node id");

input
.send(Ok(message(
2,
node_message::Body::Hello(Hello {
node: enrolled.node,
credential: enrolled.credential,
capabilities: Some(capabilities()),
capacity: Some(capacity()),
max_parallel: 1,
}),
)))
.await
.expect("hello");
let welcome = output_receiver
.recv()
.await
.expect("welcome response")
.expect("welcome status");
assert!(matches!(
welcome.body,
Some(control_message::Body::Welcome(_))
));
drop(output_receiver);

input
.send(Ok(message(
3,
node_message::Body::Heartbeat(Heartbeat {
held: Vec::new(),
capacity: Some(capacity()),
}),
)))
.await
.expect("heartbeat");
tokio::time::timeout(Duration::from_secs(1), session)
.await
.expect("session shutdown")
.expect("session task")
.expect("session cleanup");
assert_eq!(
nodes.lifecycle(node).await.expect("lifecycle"),
Lifecycle::Offline
);
}

fn message(sequence: u64, body: node_message::Body) -> NodeMessage {
NodeMessage {
sequence,
message_id: format!("message-{sequence}"),
idempotency_key: format!("message-{sequence}"),
body: Some(body),
}
}

fn capabilities() -> Capabilities {
Capabilities {
architecture: "amd64".to_owned(),
operating_system: "linux".to_owned(),
container_runtime: "docker".to_owned(),
container_runtime_version: "29.6.2".to_owned(),
cores: 2,
memory_bytes: 8 * 1024 * 1024 * 1024,
labels: vec!["ubuntu-latest".to_owned()],
}
}

fn capacity() -> Capacity {
Capacity {
build_volume_free_bytes: 60 * 1024 * 1024 * 1024,
layer_store_bytes: 10 * 1024 * 1024 * 1024,
cache_volume_present: true,
cache_volume_total_bytes: 100,
cache_volume_used_bytes: 40,
cache_volume_path: "/var/cache/syncode".to_owned(),
cached_images: Vec::new(),
cached_actions: Vec::new(),
}
}
#![allow(clippy::expect_used, clippy::panic)]

use std::time::Duration;

use syncode_control_nodes::{Ephemeral, Lifecycle, Nodes, Scope};
use syncode_control_runs::{Forgotten, Runs};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;

use super::{NodeSessionServer, run_session};
use crate::wire::{
Capabilities, Capacity, Enrol, Heartbeat, Hello, NodeMessage, control_message, node_message,
};
use crate::{ArtifactTokenAuthority, CapabilityAuthority, ProjectionClient};

#[tokio::test]
async fn an_unreadable_client_releases_its_session() {
let runs = Runs::restored(Forgotten::default()).await.expect("runs");
let nodes = Nodes::restored(Ephemeral::default()).await.expect("nodes");
let token = nodes
.issue_token(Scope::Instance)
.await
.expect("enrolment token");
let server = NodeSessionServer::new(
runs,
nodes.clone(),
CapabilityAuthority::new("test-capability-key").expect("capability authority"),
ArtifactTokenAuthority::new("test-artifact-key").expect("artifact authority"),
"http://127.0.0.1:1/".parse().expect("artifact URL"),
ProjectionClient::new(
"http://127.0.0.1:1/".parse().expect("projection URL"),
String::new(),
"http://127.0.0.1:1".to_owned(),
String::new(),
)
.expect("projection client"),
);
let (input, input_receiver) = mpsc::channel(8);
let (output, mut output_receiver) = mpsc::channel(8);
let session = tokio::spawn(async move {
let mut inbound = ReceiverStream::new(input_receiver);
run_session(&mut inbound, &output, server).await
});

input
.send(Ok(message(
1,
node_message::Body::Enrol(Enrol {
token: token.secret().expose().to_owned(),
}),
)))
.await
.expect("enrol");
let enrolled = output_receiver
.recv()
.await
.expect("enrolment response")
.expect("enrolment status");
let control_message::Body::Enrolled(enrolled) = enrolled.body.expect("enrolment body") else {
panic!("expected enrolment");
};
let node = enrolled.node.parse().expect("node id");

input
.send(Ok(message(
2,
node_message::Body::Hello(Hello {
node: enrolled.node,
credential: enrolled.credential,
capabilities: Some(capabilities()),
capacity: Some(capacity()),
max_parallel: 1,
}),
)))
.await
.expect("hello");
let welcome = output_receiver
.recv()
.await
.expect("welcome response")
.expect("welcome status");
assert!(matches!(
welcome.body,
Some(control_message::Body::Welcome(_))
));
drop(output_receiver);

input
.send(Ok(message(
3,
node_message::Body::Heartbeat(Heartbeat {
held: Vec::new(),
capacity: Some(capacity()),
}),
)))
.await
.expect("heartbeat");
tokio::time::timeout(Duration::from_secs(1), session)
.await
.expect("session shutdown")
.expect("session task")
.expect("session cleanup");
assert_eq!(
nodes.lifecycle(node).await.expect("lifecycle"),
Lifecycle::Offline
);
}

fn message(sequence: u64, body: node_message::Body) -> NodeMessage {
NodeMessage {
sequence,
message_id: format!("message-{sequence}"),
idempotency_key: format!("message-{sequence}"),
body: Some(body),
}
}

fn capabilities() -> Capabilities {
Capabilities {
architecture: "amd64".to_owned(),
operating_system: "linux".to_owned(),
container_runtime: "docker".to_owned(),
container_runtime_version: "29.6.2".to_owned(),
cores: 2,
memory_bytes: 8 * 1024 * 1024 * 1024,
labels: vec!["ubuntu-latest".to_owned()],
}
}

fn capacity() -> Capacity {
Capacity {
build_volume_free_bytes: 60 * 1024 * 1024 * 1024,
layer_store_bytes: 10 * 1024 * 1024 * 1024,
cache_volume_present: true,
cache_volume_total_bytes: 100,
cache_volume_used_bytes: 40,
cache_volume_path: "/var/cache/syncode".to_owned(),
cached_images: Vec::new(),
cached_actions: Vec::new(),
}
}
+124
View File
@@ -1,0 +1,124 @@
#![allow(clippy::expect_used)]

use std::error::Error;

use axum::Json;
use axum::http::StatusCode;
use axum::routing::post;
use syncode_control_node::ProjectionClient;
use syncode_control_node::identity_wire::identity_server::{Identity, IdentityServer};
use syncode_control_node::identity_wire::{
CheckCapabilityRequest, CheckCapabilityResponse, GetRepositoryCoordinatesRequest,
GetRepositoryCoordinatesResponse, ResolveRepositoryRequest, ResolveRepositoryResponse,
ValidateSessionRequest, ValidateSessionResponse,
};
use syncode_control_runs::{Forgotten, JobId, Origin, Runs};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio_stream::wrappers::TcpListenerStream;
use tonic::transport::Server;
use tonic::{Request, Response, Status};

type TestResult<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;

struct FixtureIdentity;

#[tonic::async_trait]
impl Identity for FixtureIdentity {
async fn validate_session(
&self,
_request: Request<ValidateSessionRequest>,
) -> Result<Response<ValidateSessionResponse>, Status> {
Err(Status::unimplemented("validate_session"))
}

async fn check_capability(
&self,
_request: Request<CheckCapabilityRequest>,
) -> Result<Response<CheckCapabilityResponse>, Status> {
Err(Status::unimplemented("check_capability"))
}

async fn resolve_repository(
&self,
_request: Request<ResolveRepositoryRequest>,
) -> Result<Response<ResolveRepositoryResponse>, Status> {
Err(Status::unimplemented("resolve_repository"))
}

async fn get_repository_coordinates(
&self,
request: Request<GetRepositoryCoordinatesRequest>,
) -> Result<Response<GetRepositoryCoordinatesResponse>, Status> {
if request
.metadata()
.get("authorization")
.and_then(|value| value.to_str().ok())
!= Some("Bearer shared-secret")
{
return Err(Status::unauthenticated("missing authorization"));
}
Ok(Response::new(GetRepositoryCoordinatesResponse {
owner: "syncode".to_owned(),
name: "pipelines-demo".to_owned(),
}))
}
}

#[tokio::test]
async fn native_repository_runs_are_projected_with_legacy_coordinates() -> TestResult {
let identity_listener = TcpListener::bind("127.0.0.1:0").await?;
let identity_endpoint = format!("http://{}", identity_listener.local_addr()?);
tokio::spawn(async move {
let _ = Server::builder()
.add_service(IdentityServer::new(FixtureIdentity))
.serve_with_incoming(TcpListenerStream::new(identity_listener))
.await;
});

let projection_listener = TcpListener::bind("127.0.0.1:0").await?;
let projection_base = format!("http://{}/", projection_listener.local_addr()?).parse()?;
let (sent, mut received) = mpsc::unbounded_channel();
let app = axum::Router::new().route(
"/api/internal/actions/syncode/projection",
post(move |Json(body): Json<serde_json::Value>| {
let sent = sent.clone();
async move {
let _ = sent.send(body);
StatusCode::NO_CONTENT
}
}),
);
tokio::spawn(async move {
let _ = axum::serve(projection_listener, app).await;
});

let runs = Runs::restored(Forgotten::default()).await?;
let run = runs
.queue(
JobId::fresh(),
Origin::new(
"76128383-1df5-4979-9b13-c048a5287e9a".to_owned(),
"commit".to_owned(),
"refs/heads/main".to_owned(),
"push".to_owned(),
".gitea/workflows/ci.yml".to_owned(),
)
.with_delivery(Some("delivery".to_owned())),
b"plan".to_vec(),
)
.await?;
let projection = runs.projection(run).await?.expect("projection");
ProjectionClient::new(
projection_base,
String::new(),
identity_endpoint,
"shared-secret".to_owned(),
)?
.send(&projection)
.await?;

let body = received.recv().await.expect("projection body");
assert_eq!(body["origin"]["repository"], "syncode/pipelines-demo");
Ok(())
}