2103 lines
66 KiB
Rust
2103 lines
66 KiB
Rust
use forage_core::auth::{
|
|
AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail,
|
|
UserProfile,
|
|
};
|
|
use forage_core::platform::{
|
|
ApprovalDecisionEntry, ApprovalState, Artifact, ArtifactContext, ArtifactDestination,
|
|
ArtifactRef, ArtifactSource, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput,
|
|
Destination, DestinationType, Environment, ForestPlatform, NotificationPreference, Organisation,
|
|
OrgMember, PipelineStage, PipelineStageConfig, PlanOutput, PlatformError, Policy, PolicyConfig,
|
|
PolicyEvaluation, ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput,
|
|
UpdateTriggerInput,
|
|
};
|
|
use forage_grpc::policy_service_client::PolicyServiceClient;
|
|
use forage_grpc::release_pipeline_service_client::ReleasePipelineServiceClient;
|
|
use forage_grpc::trigger_service_client::TriggerServiceClient;
|
|
use forage_grpc::destination_service_client::DestinationServiceClient;
|
|
use forage_grpc::environment_service_client::EnvironmentServiceClient;
|
|
use forage_grpc::organisation_service_client::OrganisationServiceClient;
|
|
use forage_grpc::release_service_client::ReleaseServiceClient;
|
|
use forage_grpc::users_service_client::UsersServiceClient;
|
|
use tonic::metadata::MetadataValue;
|
|
use tonic::transport::Channel;
|
|
use tonic::Request;
|
|
|
|
fn bearer_request<T>(access_token: &str, msg: T) -> Result<Request<T>, String> {
|
|
let mut req = Request::new(msg);
|
|
let bearer: MetadataValue<_> = format!("Bearer {access_token}")
|
|
.parse()
|
|
.map_err(|_| "invalid token format".to_string())?;
|
|
req.metadata_mut().insert("authorization", bearer);
|
|
Ok(req)
|
|
}
|
|
|
|
/// Real gRPC client to forest-server's UsersService.
|
|
#[derive(Clone)]
|
|
pub struct GrpcForestClient {
|
|
channel: Channel,
|
|
}
|
|
|
|
impl GrpcForestClient {
|
|
/// Create a client that connects lazily (for when server may not be available at startup).
|
|
pub fn connect_lazy(endpoint: &str) -> anyhow::Result<Self> {
|
|
let channel = Channel::from_shared(endpoint.to_string())?.connect_lazy();
|
|
Ok(Self { channel })
|
|
}
|
|
|
|
fn client(&self) -> UsersServiceClient<Channel> {
|
|
UsersServiceClient::new(self.channel.clone())
|
|
}
|
|
|
|
fn org_client(&self) -> OrganisationServiceClient<Channel> {
|
|
OrganisationServiceClient::new(self.channel.clone())
|
|
}
|
|
|
|
pub(crate) fn artifact_client(
|
|
&self,
|
|
) -> forage_grpc::artifact_service_client::ArtifactServiceClient<Channel> {
|
|
forage_grpc::artifact_service_client::ArtifactServiceClient::new(self.channel.clone())
|
|
}
|
|
|
|
pub(crate) fn release_client(&self) -> ReleaseServiceClient<Channel> {
|
|
ReleaseServiceClient::new(self.channel.clone())
|
|
}
|
|
|
|
fn env_client(&self) -> EnvironmentServiceClient<Channel> {
|
|
EnvironmentServiceClient::new(self.channel.clone())
|
|
}
|
|
|
|
fn dest_client(&self) -> DestinationServiceClient<Channel> {
|
|
DestinationServiceClient::new(self.channel.clone())
|
|
}
|
|
|
|
fn trigger_client(&self) -> TriggerServiceClient<Channel> {
|
|
TriggerServiceClient::new(self.channel.clone())
|
|
}
|
|
|
|
fn policy_client(&self) -> PolicyServiceClient<Channel> {
|
|
PolicyServiceClient::new(self.channel.clone())
|
|
}
|
|
|
|
fn pipeline_client(&self) -> ReleasePipelineServiceClient<Channel> {
|
|
ReleasePipelineServiceClient::new(self.channel.clone())
|
|
}
|
|
|
|
pub fn event_client(
|
|
&self,
|
|
) -> forage_grpc::event_service_client::EventServiceClient<Channel> {
|
|
forage_grpc::event_service_client::EventServiceClient::new(self.channel.clone())
|
|
}
|
|
|
|
pub(crate) fn notification_client(
|
|
&self,
|
|
) -> forage_grpc::notification_service_client::NotificationServiceClient<Channel> {
|
|
forage_grpc::notification_service_client::NotificationServiceClient::new(
|
|
self.channel.clone(),
|
|
)
|
|
}
|
|
|
|
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
|
|
bearer_request(access_token, msg).map_err(AuthError::Other)
|
|
}
|
|
|
|
/// Fetch release intent states using a service token (for background workers).
|
|
pub async fn get_release_intent_states_with_token(
|
|
&self,
|
|
service_token: &str,
|
|
organisation: &str,
|
|
project: Option<&str>,
|
|
include_completed: bool,
|
|
) -> Result<Vec<forage_core::platform::ReleaseIntentState>, String> {
|
|
let req = bearer_request(
|
|
service_token,
|
|
forage_grpc::GetReleaseIntentStatesRequest {
|
|
organisation: organisation.into(),
|
|
project: project.map(|p| p.into()),
|
|
include_completed,
|
|
},
|
|
)
|
|
.map_err(|e| format!("invalid token: {e}"))?;
|
|
|
|
let resp = self
|
|
.release_client()
|
|
.get_release_intent_states(req)
|
|
.await
|
|
.map_err(|e| format!("gRPC: {e}"))?;
|
|
|
|
Ok(resp
|
|
.into_inner()
|
|
.release_intents
|
|
.into_iter()
|
|
.map(|ri| forage_core::platform::ReleaseIntentState {
|
|
release_intent_id: ri.release_intent_id,
|
|
artifact_id: ri.artifact_id,
|
|
project: ri.project,
|
|
created_at: ri.created_at,
|
|
stages: ri.stages.into_iter().map(convert_pipeline_stage_state).collect(),
|
|
steps: ri.steps.into_iter().map(convert_release_step_state).collect(),
|
|
})
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
fn map_status(status: tonic::Status) -> AuthError {
|
|
match status.code() {
|
|
tonic::Code::Unauthenticated => AuthError::InvalidCredentials,
|
|
tonic::Code::AlreadyExists => AuthError::AlreadyExists(status.message().into()),
|
|
tonic::Code::PermissionDenied => AuthError::NotAuthenticated,
|
|
tonic::Code::Unavailable => AuthError::Unavailable(status.message().into()),
|
|
_ => AuthError::Other(status.message().into()),
|
|
}
|
|
}
|
|
|
|
fn convert_user(u: forage_grpc::User) -> User {
|
|
User {
|
|
user_id: u.user_id,
|
|
username: u.username,
|
|
emails: u
|
|
.emails
|
|
.into_iter()
|
|
.map(|e| UserEmail {
|
|
email: e.email,
|
|
verified: e.verified,
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn convert_token(t: forage_grpc::PersonalAccessToken) -> PersonalAccessToken {
|
|
PersonalAccessToken {
|
|
token_id: t.token_id,
|
|
name: t.name,
|
|
scopes: t.scopes,
|
|
created_at: t.created_at.map(|ts| ts.to_string()),
|
|
last_used: t.last_used.map(|ts| ts.to_string()),
|
|
expires_at: t.expires_at.map(|ts| ts.to_string()),
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl ForestAuth for GrpcForestClient {
|
|
async fn register(
|
|
&self,
|
|
username: &str,
|
|
email: &str,
|
|
password: &str,
|
|
) -> Result<AuthTokens, AuthError> {
|
|
let resp = self
|
|
.client()
|
|
.register(forage_grpc::RegisterRequest {
|
|
username: username.into(),
|
|
email: email.into(),
|
|
password: password.into(),
|
|
})
|
|
.await
|
|
.map_err(map_status)?
|
|
.into_inner();
|
|
|
|
let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?;
|
|
Ok(AuthTokens {
|
|
access_token: tokens.access_token,
|
|
refresh_token: tokens.refresh_token,
|
|
expires_in_seconds: tokens.expires_in_seconds,
|
|
})
|
|
}
|
|
|
|
async fn login(&self, identifier: &str, password: &str) -> Result<AuthTokens, AuthError> {
|
|
let login_identifier = if identifier.contains('@') {
|
|
forage_grpc::login_request::Identifier::Email(identifier.into())
|
|
} else {
|
|
forage_grpc::login_request::Identifier::Username(identifier.into())
|
|
};
|
|
|
|
let resp = self
|
|
.client()
|
|
.login(forage_grpc::LoginRequest {
|
|
identifier: Some(login_identifier),
|
|
password: password.into(),
|
|
})
|
|
.await
|
|
.map_err(map_status)?
|
|
.into_inner();
|
|
|
|
let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?;
|
|
Ok(AuthTokens {
|
|
access_token: tokens.access_token,
|
|
refresh_token: tokens.refresh_token,
|
|
expires_in_seconds: tokens.expires_in_seconds,
|
|
})
|
|
}
|
|
|
|
async fn refresh_token(&self, refresh_token: &str) -> Result<AuthTokens, AuthError> {
|
|
let resp = self
|
|
.client()
|
|
.refresh_token(forage_grpc::RefreshTokenRequest {
|
|
refresh_token: refresh_token.into(),
|
|
})
|
|
.await
|
|
.map_err(map_status)?
|
|
.into_inner();
|
|
|
|
let tokens = resp
|
|
.tokens
|
|
.ok_or(AuthError::Other("no tokens in response".into()))?;
|
|
Ok(AuthTokens {
|
|
access_token: tokens.access_token,
|
|
refresh_token: tokens.refresh_token,
|
|
expires_in_seconds: tokens.expires_in_seconds,
|
|
})
|
|
}
|
|
|
|
async fn logout(&self, refresh_token: &str) -> Result<(), AuthError> {
|
|
self.client()
|
|
.logout(forage_grpc::LogoutRequest {
|
|
refresh_token: refresh_token.into(),
|
|
})
|
|
.await
|
|
.map_err(map_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_user(&self, access_token: &str) -> Result<User, AuthError> {
|
|
let req = Self::authed_request(
|
|
access_token,
|
|
forage_grpc::TokenInfoRequest {},
|
|
)?;
|
|
|
|
let info = self
|
|
.client()
|
|
.token_info(req)
|
|
.await
|
|
.map_err(map_status)?
|
|
.into_inner();
|
|
|
|
let req = Self::authed_request(
|
|
access_token,
|
|
forage_grpc::GetUserRequest {
|
|
identifier: Some(forage_grpc::get_user_request::Identifier::UserId(
|
|
info.user_id,
|
|
)),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.client()
|
|
.get_user(req)
|
|
.await
|
|
.map_err(map_status)?
|
|
.into_inner();
|
|
|
|
let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?;
|
|
Ok(convert_user(user))
|
|
}
|
|
|
|
async fn get_user_by_username(
|
|
&self,
|
|
access_token: &str,
|
|
username: &str,
|
|
) -> Result<UserProfile, AuthError> {
|
|
let req = Self::authed_request(
|
|
access_token,
|
|
forage_grpc::GetUserRequest {
|
|
identifier: Some(forage_grpc::get_user_request::Identifier::Username(
|
|
username.into(),
|
|
)),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.client()
|
|
.get_user(req)
|
|
.await
|
|
.map_err(map_status)?
|
|
.into_inner();
|
|
|
|
let user = resp
|
|
.user
|
|
.ok_or(AuthError::Other("no user in response".into()))?;
|
|
Ok(UserProfile {
|
|
user_id: user.user_id,
|
|
username: user.username,
|
|
created_at: user.created_at.map(|ts| {
|
|
chrono::DateTime::from_timestamp(ts.seconds, ts.nanos as u32)
|
|
.map(|dt| dt.to_rfc3339())
|
|
.unwrap_or_default()
|
|
}),
|
|
})
|
|
}
|
|
|
|
async fn list_tokens(
|
|
&self,
|
|
access_token: &str,
|
|
user_id: &str,
|
|
) -> Result<Vec<PersonalAccessToken>, AuthError> {
|
|
let req = Self::authed_request(
|
|
access_token,
|
|
forage_grpc::ListPersonalAccessTokensRequest {
|
|
user_id: user_id.into(),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.client()
|
|
.list_personal_access_tokens(req)
|
|
.await
|
|
.map_err(map_status)?
|
|
.into_inner();
|
|
|
|
Ok(resp.tokens.into_iter().map(convert_token).collect())
|
|
}
|
|
|
|
async fn create_token(
|
|
&self,
|
|
access_token: &str,
|
|
user_id: &str,
|
|
name: &str,
|
|
) -> Result<CreatedToken, AuthError> {
|
|
let req = Self::authed_request(
|
|
access_token,
|
|
forage_grpc::CreatePersonalAccessTokenRequest {
|
|
user_id: user_id.into(),
|
|
name: name.into(),
|
|
scopes: vec![],
|
|
expires_in_seconds: 0,
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.client()
|
|
.create_personal_access_token(req)
|
|
.await
|
|
.map_err(map_status)?
|
|
.into_inner();
|
|
|
|
let token = resp
|
|
.token
|
|
.ok_or(AuthError::Other("no token in response".into()))?;
|
|
Ok(CreatedToken {
|
|
token: convert_token(token),
|
|
raw_token: resp.raw_token,
|
|
})
|
|
}
|
|
|
|
async fn delete_token(
|
|
&self,
|
|
access_token: &str,
|
|
token_id: &str,
|
|
) -> Result<(), AuthError> {
|
|
let req = Self::authed_request(
|
|
access_token,
|
|
forage_grpc::DeletePersonalAccessTokenRequest {
|
|
token_id: token_id.into(),
|
|
},
|
|
)?;
|
|
|
|
self.client()
|
|
.delete_personal_access_token(req)
|
|
.await
|
|
.map_err(map_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn update_username(
|
|
&self,
|
|
access_token: &str,
|
|
user_id: &str,
|
|
new_username: &str,
|
|
) -> Result<User, AuthError> {
|
|
let req = Self::authed_request(
|
|
access_token,
|
|
forage_grpc::UpdateUserRequest {
|
|
user_id: user_id.into(),
|
|
username: Some(new_username.into()),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.client()
|
|
.update_user(req)
|
|
.await
|
|
.map_err(map_status)?
|
|
.into_inner();
|
|
|
|
let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?;
|
|
Ok(convert_user(user))
|
|
}
|
|
|
|
async fn change_password(
|
|
&self,
|
|
access_token: &str,
|
|
user_id: &str,
|
|
current_password: &str,
|
|
new_password: &str,
|
|
) -> Result<(), AuthError> {
|
|
let req = Self::authed_request(
|
|
access_token,
|
|
forage_grpc::ChangePasswordRequest {
|
|
user_id: user_id.into(),
|
|
current_password: current_password.into(),
|
|
new_password: new_password.into(),
|
|
},
|
|
)?;
|
|
|
|
self.client()
|
|
.change_password(req)
|
|
.await
|
|
.map_err(map_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn add_email(
|
|
&self,
|
|
access_token: &str,
|
|
user_id: &str,
|
|
email: &str,
|
|
) -> Result<UserEmail, AuthError> {
|
|
let req = Self::authed_request(
|
|
access_token,
|
|
forage_grpc::AddEmailRequest {
|
|
user_id: user_id.into(),
|
|
email: email.into(),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.client()
|
|
.add_email(req)
|
|
.await
|
|
.map_err(map_status)?
|
|
.into_inner();
|
|
|
|
let email = resp.email.ok_or(AuthError::Other("no email in response".into()))?;
|
|
Ok(UserEmail {
|
|
email: email.email,
|
|
verified: email.verified,
|
|
})
|
|
}
|
|
|
|
async fn remove_email(
|
|
&self,
|
|
access_token: &str,
|
|
user_id: &str,
|
|
email: &str,
|
|
) -> Result<(), AuthError> {
|
|
let req = Self::authed_request(
|
|
access_token,
|
|
forage_grpc::RemoveEmailRequest {
|
|
user_id: user_id.into(),
|
|
email: email.into(),
|
|
},
|
|
)?;
|
|
|
|
self.client()
|
|
.remove_email(req)
|
|
.await
|
|
.map_err(map_status)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn convert_organisations(
|
|
organisations: Vec<forage_grpc::Organisation>,
|
|
roles: Vec<String>,
|
|
) -> Vec<Organisation> {
|
|
organisations
|
|
.into_iter()
|
|
.zip(roles)
|
|
.map(|(org, role)| Organisation {
|
|
organisation_id: org.organisation_id,
|
|
name: org.name,
|
|
role,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
|
|
let ctx = a.context.unwrap_or_default();
|
|
let source = a.source.map(|s| ArtifactSource {
|
|
user: s.user.filter(|v| !v.is_empty()),
|
|
email: s.email.filter(|v| !v.is_empty()),
|
|
source_type: s.source_type.filter(|v| !v.is_empty()),
|
|
run_url: s.run_url.filter(|v| !v.is_empty()),
|
|
});
|
|
let git_ref = a.r#ref.map(|r| ArtifactRef {
|
|
commit_sha: r.commit_sha,
|
|
branch: r.branch.filter(|v| !v.is_empty()),
|
|
commit_message: r.commit_message.filter(|v| !v.is_empty()),
|
|
version: r.version.filter(|v| !v.is_empty()),
|
|
repo_url: r.repo_url.filter(|v| !v.is_empty()),
|
|
});
|
|
let destinations = a
|
|
.destinations
|
|
.into_iter()
|
|
.map(|d| ArtifactDestination {
|
|
name: d.name,
|
|
environment: d.environment,
|
|
type_organisation: if d.type_organisation.is_empty() {
|
|
None
|
|
} else {
|
|
Some(d.type_organisation)
|
|
},
|
|
type_name: if d.type_name.is_empty() {
|
|
None
|
|
} else {
|
|
Some(d.type_name)
|
|
},
|
|
type_version: if d.type_version == 0 {
|
|
None
|
|
} else {
|
|
Some(d.type_version)
|
|
},
|
|
status: if d.status.is_empty() {
|
|
None
|
|
} else {
|
|
Some(d.status)
|
|
},
|
|
})
|
|
.collect();
|
|
Artifact {
|
|
artifact_id: a.artifact_id,
|
|
slug: a.slug,
|
|
context: ArtifactContext {
|
|
title: ctx.title,
|
|
description: if ctx.description.as_deref() == Some("") {
|
|
None
|
|
} else {
|
|
ctx.description
|
|
},
|
|
web: ctx.web.filter(|v| !v.is_empty()),
|
|
pr: ctx.pr.filter(|v| !v.is_empty()),
|
|
},
|
|
source,
|
|
git_ref,
|
|
destinations,
|
|
created_at: a.created_at,
|
|
}
|
|
}
|
|
|
|
fn convert_pipeline_stage(s: forage_grpc::PipelineStage) -> PipelineStage {
|
|
let config = match s.config {
|
|
Some(forage_grpc::pipeline_stage::Config::Deploy(d)) => {
|
|
PipelineStageConfig::Deploy { environment: d.environment }
|
|
}
|
|
Some(forage_grpc::pipeline_stage::Config::Wait(w)) => {
|
|
PipelineStageConfig::Wait { duration_seconds: w.duration_seconds }
|
|
}
|
|
Some(forage_grpc::pipeline_stage::Config::Plan(p)) => {
|
|
PipelineStageConfig::Plan { environment: p.environment, auto_approve: p.auto_approve }
|
|
}
|
|
None => PipelineStageConfig::Deploy { environment: String::new() },
|
|
};
|
|
PipelineStage {
|
|
id: s.id,
|
|
depends_on: s.depends_on,
|
|
config,
|
|
}
|
|
}
|
|
|
|
/// Convert a `PipelineStageState` proto message (from GetReleaseIntentStates)
|
|
/// to the domain type. Same enum mapping as `convert_pipeline_run_stage`.
|
|
fn convert_pipeline_stage_state(
|
|
s: forage_grpc::PipelineStageState,
|
|
) -> forage_core::platform::PipelineRunStageState {
|
|
let stage_type = match forage_grpc::PipelineRunStageType::try_from(s.stage_type) {
|
|
Ok(forage_grpc::PipelineRunStageType::Deploy) => "deploy",
|
|
Ok(forage_grpc::PipelineRunStageType::Wait) => "wait",
|
|
Ok(forage_grpc::PipelineRunStageType::Plan) => "plan",
|
|
_ => "unknown",
|
|
};
|
|
let status = match forage_grpc::PipelineRunStageStatus::try_from(s.status) {
|
|
Ok(forage_grpc::PipelineRunStageStatus::Pending) => "PENDING",
|
|
Ok(forage_grpc::PipelineRunStageStatus::Active) => "RUNNING",
|
|
Ok(forage_grpc::PipelineRunStageStatus::Succeeded) => "SUCCEEDED",
|
|
Ok(forage_grpc::PipelineRunStageStatus::Failed) => "FAILED",
|
|
Ok(forage_grpc::PipelineRunStageStatus::Cancelled) => "CANCELLED",
|
|
Ok(forage_grpc::PipelineRunStageStatus::AwaitingApproval) => "AWAITING_APPROVAL",
|
|
_ => "PENDING",
|
|
};
|
|
forage_core::platform::PipelineRunStageState {
|
|
stage_id: s.stage_id,
|
|
depends_on: s.depends_on,
|
|
stage_type: stage_type.into(),
|
|
status: status.into(),
|
|
environment: s.environment,
|
|
duration_seconds: s.duration_seconds,
|
|
queued_at: s.queued_at,
|
|
started_at: s.started_at,
|
|
completed_at: s.completed_at,
|
|
error_message: s.error_message,
|
|
wait_until: s.wait_until,
|
|
release_ids: s.release_ids,
|
|
approval_status: s.approval_status,
|
|
auto_approve: s.auto_approve,
|
|
}
|
|
}
|
|
|
|
fn convert_release_step_state(
|
|
s: forage_grpc::ReleaseStepState,
|
|
) -> forage_core::platform::ReleaseStepState {
|
|
forage_core::platform::ReleaseStepState {
|
|
release_id: s.release_id,
|
|
stage_id: s.stage_id,
|
|
destination_name: s.destination_name,
|
|
environment: s.environment,
|
|
status: s.status,
|
|
queued_at: s.queued_at,
|
|
assigned_at: s.assigned_at,
|
|
started_at: s.started_at,
|
|
completed_at: s.completed_at,
|
|
error_message: s.error_message,
|
|
}
|
|
}
|
|
|
|
fn convert_stages_to_grpc(stages: &[PipelineStage]) -> Vec<forage_grpc::PipelineStage> {
|
|
stages
|
|
.iter()
|
|
.map(|s| forage_grpc::PipelineStage {
|
|
id: s.id.clone(),
|
|
depends_on: s.depends_on.clone(),
|
|
config: Some(match &s.config {
|
|
PipelineStageConfig::Deploy { environment } => {
|
|
forage_grpc::pipeline_stage::Config::Deploy(forage_grpc::DeployStageConfig {
|
|
environment: environment.clone(),
|
|
})
|
|
}
|
|
PipelineStageConfig::Wait { duration_seconds } => {
|
|
forage_grpc::pipeline_stage::Config::Wait(forage_grpc::WaitStageConfig {
|
|
duration_seconds: *duration_seconds,
|
|
})
|
|
}
|
|
PipelineStageConfig::Plan { environment, auto_approve } => {
|
|
forage_grpc::pipeline_stage::Config::Plan(forage_grpc::PlanStageConfig {
|
|
environment: environment.clone(),
|
|
auto_approve: *auto_approve,
|
|
})
|
|
}
|
|
}),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn convert_release_pipeline(p: forage_grpc::ReleasePipeline) -> ReleasePipeline {
|
|
ReleasePipeline {
|
|
id: p.id,
|
|
name: p.name,
|
|
enabled: p.enabled,
|
|
stages: p.stages.into_iter().map(convert_pipeline_stage).collect(),
|
|
created_at: p.created_at,
|
|
updated_at: p.updated_at,
|
|
}
|
|
}
|
|
|
|
fn convert_trigger(t: forage_grpc::Trigger) -> Trigger {
|
|
Trigger {
|
|
id: t.id,
|
|
name: t.name,
|
|
enabled: t.enabled,
|
|
branch_pattern: t.branch_pattern,
|
|
title_pattern: t.title_pattern,
|
|
author_pattern: t.author_pattern,
|
|
commit_message_pattern: t.commit_message_pattern,
|
|
source_type_pattern: t.source_type_pattern,
|
|
target_environments: t.target_environments,
|
|
target_destinations: t.target_destinations,
|
|
force_release: t.force_release,
|
|
use_pipeline: t.use_pipeline,
|
|
created_at: t.created_at,
|
|
updated_at: t.updated_at,
|
|
}
|
|
}
|
|
|
|
fn convert_policy(p: forage_grpc::Policy) -> Policy {
|
|
let policy_type_str = match forage_grpc::PolicyType::try_from(p.policy_type) {
|
|
Ok(forage_grpc::PolicyType::SoakTime) => "soak_time",
|
|
Ok(forage_grpc::PolicyType::BranchRestriction) => "branch_restriction",
|
|
Ok(forage_grpc::PolicyType::ExternalApproval) => "approval",
|
|
_ => "unknown",
|
|
};
|
|
let config = match p.config {
|
|
Some(forage_grpc::policy::Config::SoakTime(c)) => PolicyConfig::SoakTime {
|
|
source_environment: c.source_environment,
|
|
target_environment: c.target_environment,
|
|
duration_seconds: c.duration_seconds,
|
|
},
|
|
Some(forage_grpc::policy::Config::BranchRestriction(c)) => {
|
|
PolicyConfig::BranchRestriction {
|
|
target_environment: c.target_environment,
|
|
branch_pattern: c.branch_pattern,
|
|
}
|
|
}
|
|
Some(forage_grpc::policy::Config::ExternalApproval(c)) => PolicyConfig::Approval {
|
|
target_environment: c.target_environment,
|
|
required_approvals: c.required_approvals,
|
|
},
|
|
None => PolicyConfig::SoakTime {
|
|
source_environment: String::new(),
|
|
target_environment: String::new(),
|
|
duration_seconds: 0,
|
|
},
|
|
};
|
|
Policy {
|
|
id: p.id,
|
|
name: p.name,
|
|
enabled: p.enabled,
|
|
policy_type: policy_type_str.into(),
|
|
config,
|
|
created_at: p.created_at,
|
|
updated_at: p.updated_at,
|
|
}
|
|
}
|
|
|
|
fn policy_config_to_grpc(
|
|
config: &PolicyConfig,
|
|
) -> (i32, Option<forage_grpc::create_policy_request::Config>) {
|
|
match config {
|
|
PolicyConfig::SoakTime {
|
|
source_environment,
|
|
target_environment,
|
|
duration_seconds,
|
|
} => (
|
|
forage_grpc::PolicyType::SoakTime as i32,
|
|
Some(forage_grpc::create_policy_request::Config::SoakTime(
|
|
forage_grpc::SoakTimeConfig {
|
|
source_environment: source_environment.clone(),
|
|
target_environment: target_environment.clone(),
|
|
duration_seconds: *duration_seconds,
|
|
},
|
|
)),
|
|
),
|
|
PolicyConfig::BranchRestriction {
|
|
target_environment,
|
|
branch_pattern,
|
|
} => (
|
|
forage_grpc::PolicyType::BranchRestriction as i32,
|
|
Some(
|
|
forage_grpc::create_policy_request::Config::BranchRestriction(
|
|
forage_grpc::BranchRestrictionConfig {
|
|
target_environment: target_environment.clone(),
|
|
branch_pattern: branch_pattern.clone(),
|
|
},
|
|
),
|
|
),
|
|
),
|
|
PolicyConfig::Approval {
|
|
target_environment,
|
|
required_approvals,
|
|
} => (
|
|
forage_grpc::PolicyType::ExternalApproval as i32,
|
|
Some(
|
|
forage_grpc::create_policy_request::Config::ExternalApproval(
|
|
forage_grpc::ExternalApprovalConfig {
|
|
target_environment: target_environment.clone(),
|
|
required_approvals: *required_approvals,
|
|
},
|
|
),
|
|
),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn convert_member(m: forage_grpc::OrganisationMember) -> OrgMember {
|
|
OrgMember {
|
|
user_id: m.user_id,
|
|
username: m.username,
|
|
role: m.role,
|
|
joined_at: m.joined_at.map(|ts| ts.to_string()),
|
|
}
|
|
}
|
|
|
|
fn map_platform_status(status: tonic::Status) -> PlatformError {
|
|
match status.code() {
|
|
tonic::Code::Unauthenticated => PlatformError::NotAuthenticated,
|
|
tonic::Code::PermissionDenied => {
|
|
PlatformError::Other(status.message().into())
|
|
}
|
|
tonic::Code::NotFound => PlatformError::NotFound(status.message().into()),
|
|
tonic::Code::Unavailable => PlatformError::Unavailable(status.message().into()),
|
|
_ => PlatformError::Other(status.message().into()),
|
|
}
|
|
}
|
|
|
|
fn platform_authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, PlatformError> {
|
|
bearer_request(access_token, msg).map_err(PlatformError::Other)
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl ForestPlatform for GrpcForestClient {
|
|
async fn list_my_organisations(
|
|
&self,
|
|
access_token: &str,
|
|
) -> Result<Vec<Organisation>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::ListMyOrganisationsRequest { role: String::new() },
|
|
)?;
|
|
|
|
let resp = self
|
|
.org_client()
|
|
.list_my_organisations(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
|
|
Ok(convert_organisations(resp.organisations, resp.roles))
|
|
}
|
|
|
|
async fn list_projects(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
) -> Result<Vec<String>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::GetProjectsRequest {
|
|
query: Some(forage_grpc::get_projects_request::Query::Organisation(
|
|
forage_grpc::OrganisationRef {
|
|
organisation: organisation.into(),
|
|
},
|
|
)),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.release_client()
|
|
.get_projects(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
|
|
Ok(resp.projects)
|
|
}
|
|
|
|
async fn list_artifacts(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
) -> Result<Vec<Artifact>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::GetArtifactsByProjectRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.release_client()
|
|
.get_artifacts_by_project(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
|
|
Ok(resp.artifact.into_iter().map(convert_artifact).collect())
|
|
}
|
|
|
|
async fn create_organisation(
|
|
&self,
|
|
access_token: &str,
|
|
name: &str,
|
|
) -> Result<String, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::CreateOrganisationRequest {
|
|
name: name.into(),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.org_client()
|
|
.create_organisation(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
|
|
Ok(resp.organisation_id)
|
|
}
|
|
|
|
async fn list_members(
|
|
&self,
|
|
access_token: &str,
|
|
organisation_id: &str,
|
|
) -> Result<Vec<OrgMember>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::ListMembersRequest {
|
|
organisation_id: organisation_id.into(),
|
|
page_size: 100,
|
|
page_token: String::new(),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.org_client()
|
|
.list_members(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
|
|
Ok(resp.members.into_iter().map(convert_member).collect())
|
|
}
|
|
|
|
async fn add_member(
|
|
&self,
|
|
access_token: &str,
|
|
organisation_id: &str,
|
|
user_id: &str,
|
|
role: &str,
|
|
) -> Result<OrgMember, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::AddMemberRequest {
|
|
organisation_id: organisation_id.into(),
|
|
user_id: user_id.into(),
|
|
role: role.into(),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.org_client()
|
|
.add_member(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
|
|
let member = resp
|
|
.member
|
|
.ok_or(PlatformError::Other("no member in response".into()))?;
|
|
Ok(convert_member(member))
|
|
}
|
|
|
|
async fn remove_member(
|
|
&self,
|
|
access_token: &str,
|
|
organisation_id: &str,
|
|
user_id: &str,
|
|
) -> Result<(), PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::RemoveMemberRequest {
|
|
organisation_id: organisation_id.into(),
|
|
user_id: user_id.into(),
|
|
},
|
|
)?;
|
|
|
|
self.org_client()
|
|
.remove_member(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn update_member_role(
|
|
&self,
|
|
access_token: &str,
|
|
organisation_id: &str,
|
|
user_id: &str,
|
|
role: &str,
|
|
) -> Result<OrgMember, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::UpdateMemberRoleRequest {
|
|
organisation_id: organisation_id.into(),
|
|
user_id: user_id.into(),
|
|
role: role.into(),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.org_client()
|
|
.update_member_role(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
|
|
let member = resp
|
|
.member
|
|
.ok_or(PlatformError::Other("no member in response".into()))?;
|
|
Ok(convert_member(member))
|
|
}
|
|
|
|
async fn get_artifact_by_slug(
|
|
&self,
|
|
access_token: &str,
|
|
slug: &str,
|
|
) -> Result<Artifact, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::GetArtifactBySlugRequest {
|
|
slug: slug.into(),
|
|
},
|
|
)?;
|
|
|
|
let resp = self
|
|
.release_client()
|
|
.get_artifact_by_slug(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
|
|
let artifact = resp
|
|
.artifact
|
|
.ok_or(PlatformError::NotFound("artifact not found".into()))?;
|
|
Ok(convert_artifact(artifact))
|
|
}
|
|
|
|
async fn list_environments(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
) -> Result<Vec<Environment>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::ListEnvironmentsRequest {
|
|
organisation: organisation.into(),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.env_client()
|
|
.list_environments(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
Ok(resp
|
|
.environments
|
|
.into_iter()
|
|
.map(|e| Environment {
|
|
id: e.id,
|
|
organisation: e.organisation,
|
|
name: e.name,
|
|
description: e.description.filter(|v| !v.is_empty()),
|
|
sort_order: e.sort_order,
|
|
created_at: e.created_at,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn list_destinations(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
) -> Result<Vec<Destination>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::GetDestinationsRequest {
|
|
organisation: organisation.into(),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.dest_client()
|
|
.get_destinations(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
Ok(resp
|
|
.destinations
|
|
.into_iter()
|
|
.map(|d| Destination {
|
|
name: d.name,
|
|
environment: d.environment,
|
|
organisation: d.organisation,
|
|
metadata: d.metadata,
|
|
dest_type: d.r#type.map(|t| DestinationType {
|
|
organisation: t.organisation,
|
|
name: t.name,
|
|
version: t.version,
|
|
}),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn create_environment(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
name: &str,
|
|
description: Option<&str>,
|
|
sort_order: i32,
|
|
) -> Result<Environment, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::CreateEnvironmentRequest {
|
|
organisation: organisation.into(),
|
|
name: name.into(),
|
|
description: description.map(|s| s.to_string()),
|
|
sort_order,
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.env_client()
|
|
.create_environment(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
let e = resp
|
|
.environment
|
|
.ok_or(PlatformError::Other("no environment in response".into()))?;
|
|
Ok(Environment {
|
|
id: e.id,
|
|
organisation: e.organisation,
|
|
name: e.name,
|
|
description: e.description.filter(|v| !v.is_empty()),
|
|
sort_order: e.sort_order,
|
|
created_at: e.created_at,
|
|
})
|
|
}
|
|
|
|
async fn create_destination(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
name: &str,
|
|
environment: &str,
|
|
metadata: &std::collections::HashMap<String, String>,
|
|
dest_type: Option<&forage_core::platform::DestinationType>,
|
|
) -> Result<(), PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::CreateDestinationRequest {
|
|
organisation: organisation.into(),
|
|
name: name.into(),
|
|
environment: environment.into(),
|
|
metadata: metadata.clone(),
|
|
r#type: dest_type.map(|t| forage_grpc::DestinationType {
|
|
organisation: t.organisation.clone(),
|
|
name: t.name.clone(),
|
|
version: t.version,
|
|
}),
|
|
},
|
|
)?;
|
|
self.dest_client()
|
|
.create_destination(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn update_destination(
|
|
&self,
|
|
access_token: &str,
|
|
name: &str,
|
|
metadata: &std::collections::HashMap<String, String>,
|
|
) -> Result<(), PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::UpdateDestinationRequest {
|
|
name: name.into(),
|
|
metadata: metadata.clone(),
|
|
},
|
|
)?;
|
|
self.dest_client()
|
|
.update_destination(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_destination_states(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: Option<&str>,
|
|
) -> Result<forage_core::platform::DeploymentStates, PlatformError> {
|
|
let req = bearer_request(
|
|
access_token,
|
|
forage_grpc::GetDestinationStatesRequest {
|
|
organisation: organisation.into(),
|
|
project: project.map(|p| p.into()),
|
|
},
|
|
)
|
|
.map_err(|e| PlatformError::Other(e.to_string()))?;
|
|
|
|
let resp = self
|
|
.release_client()
|
|
.get_destination_states(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
|
|
let inner = resp.into_inner();
|
|
|
|
let destinations = inner
|
|
.destinations
|
|
.into_iter()
|
|
.map(|d| forage_core::platform::DestinationState {
|
|
destination_id: d.destination_id,
|
|
destination_name: d.destination_name,
|
|
environment: d.environment,
|
|
release_id: d.release_id,
|
|
artifact_id: d.artifact_id,
|
|
status: d.status,
|
|
error_message: d.error_message,
|
|
queued_at: d.queued_at,
|
|
completed_at: d.completed_at,
|
|
queue_position: d.queue_position,
|
|
started_at: d.started_at,
|
|
})
|
|
.collect();
|
|
|
|
Ok(forage_core::platform::DeploymentStates {
|
|
destinations,
|
|
})
|
|
}
|
|
|
|
async fn get_release_intent_states(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: Option<&str>,
|
|
include_completed: bool,
|
|
) -> Result<Vec<forage_core::platform::ReleaseIntentState>, PlatformError> {
|
|
let req = bearer_request(
|
|
access_token,
|
|
forage_grpc::GetReleaseIntentStatesRequest {
|
|
organisation: organisation.into(),
|
|
project: project.map(|p| p.into()),
|
|
include_completed,
|
|
},
|
|
)
|
|
.map_err(|e| PlatformError::Other(e.to_string()))?;
|
|
|
|
let resp = self
|
|
.release_client()
|
|
.get_release_intent_states(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
|
|
Ok(resp
|
|
.into_inner()
|
|
.release_intents
|
|
.into_iter()
|
|
.map(|ri| forage_core::platform::ReleaseIntentState {
|
|
release_intent_id: ri.release_intent_id,
|
|
artifact_id: ri.artifact_id,
|
|
project: ri.project,
|
|
created_at: ri.created_at,
|
|
stages: ri
|
|
.stages
|
|
.into_iter()
|
|
.map(convert_pipeline_stage_state)
|
|
.collect(),
|
|
steps: ri
|
|
.steps
|
|
.into_iter()
|
|
.map(convert_release_step_state)
|
|
.collect(),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn release_artifact(
|
|
&self,
|
|
access_token: &str,
|
|
artifact_id: &str,
|
|
destinations: &[String],
|
|
environments: &[String],
|
|
use_pipeline: bool,
|
|
) -> Result<(), PlatformError> {
|
|
let req = bearer_request(
|
|
access_token,
|
|
forage_grpc::ReleaseRequest {
|
|
artifact_id: artifact_id.into(),
|
|
destinations: destinations.to_vec(),
|
|
environments: environments.to_vec(),
|
|
force: false,
|
|
use_pipeline,
|
|
prepare_only: false,
|
|
},
|
|
)
|
|
.map_err(|e| PlatformError::Other(e.to_string()))?;
|
|
|
|
self.release_client()
|
|
.release(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_triggers(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
) -> Result<Vec<Trigger>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::ListTriggersRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.trigger_client()
|
|
.list_triggers(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
Ok(resp.triggers.into_iter().map(convert_trigger).collect())
|
|
}
|
|
|
|
async fn create_trigger(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
input: &CreateTriggerInput,
|
|
) -> Result<Trigger, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::CreateTriggerRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
name: input.name.clone(),
|
|
branch_pattern: input.branch_pattern.clone(),
|
|
title_pattern: input.title_pattern.clone(),
|
|
author_pattern: input.author_pattern.clone(),
|
|
commit_message_pattern: input.commit_message_pattern.clone(),
|
|
source_type_pattern: input.source_type_pattern.clone(),
|
|
target_environments: input.target_environments.clone(),
|
|
target_destinations: input.target_destinations.clone(),
|
|
force_release: input.force_release,
|
|
use_pipeline: input.use_pipeline,
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.trigger_client()
|
|
.create_trigger(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
let trigger = resp
|
|
.trigger
|
|
.ok_or(PlatformError::Other("no trigger in response".into()))?;
|
|
Ok(convert_trigger(trigger))
|
|
}
|
|
|
|
async fn update_trigger(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
name: &str,
|
|
input: &UpdateTriggerInput,
|
|
) -> Result<Trigger, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::UpdateTriggerRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
name: name.into(),
|
|
enabled: input.enabled,
|
|
branch_pattern: input.branch_pattern.clone(),
|
|
title_pattern: input.title_pattern.clone(),
|
|
author_pattern: input.author_pattern.clone(),
|
|
commit_message_pattern: input.commit_message_pattern.clone(),
|
|
source_type_pattern: input.source_type_pattern.clone(),
|
|
target_environments: input.target_environments.clone(),
|
|
target_destinations: input.target_destinations.clone(),
|
|
force_release: input.force_release,
|
|
use_pipeline: input.use_pipeline,
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.trigger_client()
|
|
.update_trigger(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
let trigger = resp
|
|
.trigger
|
|
.ok_or(PlatformError::Other("no trigger in response".into()))?;
|
|
Ok(convert_trigger(trigger))
|
|
}
|
|
|
|
async fn delete_trigger(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
name: &str,
|
|
) -> Result<(), PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::DeleteTriggerRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
name: name.into(),
|
|
},
|
|
)?;
|
|
self.trigger_client()
|
|
.delete_trigger(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_policies(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
) -> Result<Vec<Policy>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::ListPoliciesRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.policy_client()
|
|
.list_policies(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
Ok(resp.policies.into_iter().map(convert_policy).collect())
|
|
}
|
|
|
|
async fn create_policy(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
input: &CreatePolicyInput,
|
|
) -> Result<Policy, PlatformError> {
|
|
let (policy_type, config) = policy_config_to_grpc(&input.config);
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::CreatePolicyRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
name: input.name.clone(),
|
|
policy_type,
|
|
config,
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.policy_client()
|
|
.create_policy(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
let policy = resp
|
|
.policy
|
|
.ok_or(PlatformError::Other("no policy in response".into()))?;
|
|
Ok(convert_policy(policy))
|
|
}
|
|
|
|
async fn update_policy(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
name: &str,
|
|
input: &UpdatePolicyInput,
|
|
) -> Result<Policy, PlatformError> {
|
|
let config = input.config.as_ref().map(|c| {
|
|
let (_, grpc_config) = policy_config_to_grpc(c);
|
|
match grpc_config {
|
|
Some(forage_grpc::create_policy_request::Config::SoakTime(s)) => {
|
|
forage_grpc::update_policy_request::Config::SoakTime(s)
|
|
}
|
|
Some(forage_grpc::create_policy_request::Config::BranchRestriction(b)) => {
|
|
forage_grpc::update_policy_request::Config::BranchRestriction(b)
|
|
}
|
|
Some(forage_grpc::create_policy_request::Config::ExternalApproval(a)) => {
|
|
forage_grpc::update_policy_request::Config::ExternalApproval(a)
|
|
}
|
|
None => forage_grpc::update_policy_request::Config::SoakTime(
|
|
forage_grpc::SoakTimeConfig::default(),
|
|
),
|
|
}
|
|
});
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::UpdatePolicyRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
name: name.into(),
|
|
enabled: input.enabled,
|
|
config,
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.policy_client()
|
|
.update_policy(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
let policy = resp
|
|
.policy
|
|
.ok_or(PlatformError::Other("no policy in response".into()))?;
|
|
Ok(convert_policy(policy))
|
|
}
|
|
|
|
async fn delete_policy(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
name: &str,
|
|
) -> Result<(), PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::DeletePolicyRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
name: name.into(),
|
|
},
|
|
)?;
|
|
self.policy_client()
|
|
.delete_policy(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_release_pipelines(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
) -> Result<Vec<ReleasePipeline>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::ListReleasePipelinesRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.pipeline_client()
|
|
.list_release_pipelines(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
Ok(resp
|
|
.pipelines
|
|
.into_iter()
|
|
.map(convert_release_pipeline)
|
|
.collect())
|
|
}
|
|
|
|
async fn create_release_pipeline(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
input: &CreateReleasePipelineInput,
|
|
) -> Result<ReleasePipeline, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::CreateReleasePipelineRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
name: input.name.clone(),
|
|
stages: convert_stages_to_grpc(&input.stages),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.pipeline_client()
|
|
.create_release_pipeline(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
let pipeline = resp
|
|
.pipeline
|
|
.ok_or(PlatformError::Other("no pipeline in response".into()))?;
|
|
Ok(convert_release_pipeline(pipeline))
|
|
}
|
|
|
|
async fn update_release_pipeline(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
name: &str,
|
|
input: &UpdateReleasePipelineInput,
|
|
) -> Result<ReleasePipeline, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::UpdateReleasePipelineRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
name: name.into(),
|
|
enabled: input.enabled,
|
|
stages: input.stages.as_ref().map(|s| convert_stages_to_grpc(s)).unwrap_or_default(),
|
|
update_stages: input.stages.is_some(),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.pipeline_client()
|
|
.update_release_pipeline(req)
|
|
.await
|
|
.map_err(map_platform_status)?
|
|
.into_inner();
|
|
let pipeline = resp
|
|
.pipeline
|
|
.ok_or(PlatformError::Other("no pipeline in response".into()))?;
|
|
Ok(convert_release_pipeline(pipeline))
|
|
}
|
|
|
|
async fn delete_release_pipeline(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
name: &str,
|
|
) -> Result<(), PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::DeleteReleasePipelineRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
name: name.into(),
|
|
},
|
|
)?;
|
|
self.pipeline_client()
|
|
.delete_release_pipeline(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_artifact_spec(
|
|
&self,
|
|
access_token: &str,
|
|
artifact_id: &str,
|
|
) -> Result<String, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::GetArtifactSpecRequest {
|
|
artifact_id: artifact_id.into(),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.artifact_client()
|
|
.get_artifact_spec(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(resp.into_inner().content)
|
|
}
|
|
|
|
async fn get_notification_preferences(
|
|
&self,
|
|
access_token: &str,
|
|
) -> Result<Vec<NotificationPreference>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::GetNotificationPreferencesRequest {},
|
|
)?;
|
|
let resp = self
|
|
.notification_client()
|
|
.get_notification_preferences(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(resp
|
|
.into_inner()
|
|
.preferences
|
|
.into_iter()
|
|
.map(|p| {
|
|
let nt = forage_grpc::NotificationType::try_from(p.notification_type)
|
|
.unwrap_or(forage_grpc::NotificationType::Unspecified);
|
|
let ch = forage_grpc::NotificationChannel::try_from(p.channel)
|
|
.unwrap_or(forage_grpc::NotificationChannel::Unspecified);
|
|
NotificationPreference {
|
|
notification_type: nt.as_str_name().to_string(),
|
|
channel: ch.as_str_name().to_string(),
|
|
enabled: p.enabled,
|
|
}
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn set_notification_preference(
|
|
&self,
|
|
access_token: &str,
|
|
notification_type: &str,
|
|
channel: &str,
|
|
enabled: bool,
|
|
) -> Result<(), PlatformError> {
|
|
let nt = forage_grpc::NotificationType::from_str_name(notification_type)
|
|
.unwrap_or(forage_grpc::NotificationType::Unspecified) as i32;
|
|
let ch = forage_grpc::NotificationChannel::from_str_name(channel)
|
|
.unwrap_or(forage_grpc::NotificationChannel::Unspecified) as i32;
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::SetNotificationPreferenceRequest {
|
|
notification_type: nt,
|
|
channel: ch,
|
|
enabled,
|
|
},
|
|
)?;
|
|
self.notification_client()
|
|
.set_notification_preference(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn evaluate_policies(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
target_environment: &str,
|
|
release_intent_id: Option<&str>,
|
|
) -> Result<Vec<PolicyEvaluation>, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::EvaluatePoliciesRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
target_environment: target_environment.into(),
|
|
branch: None,
|
|
release_intent_id: release_intent_id.map(|s| s.to_string()),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.policy_client()
|
|
.evaluate_policies(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(resp
|
|
.into_inner()
|
|
.evaluations
|
|
.into_iter()
|
|
.map(convert_policy_evaluation)
|
|
.collect())
|
|
}
|
|
|
|
async fn approve_release(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
release_intent_id: &str,
|
|
target_environment: &str,
|
|
comment: Option<&str>,
|
|
force_bypass: bool,
|
|
) -> Result<ApprovalState, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::ExternalApproveReleaseRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
release_intent_id: release_intent_id.into(),
|
|
target_environment: target_environment.into(),
|
|
comment: comment.map(|s| s.to_string()),
|
|
force_bypass,
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.policy_client()
|
|
.external_approve_release(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(convert_approval_state(resp.into_inner().state))
|
|
}
|
|
|
|
async fn reject_release(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
release_intent_id: &str,
|
|
target_environment: &str,
|
|
comment: Option<&str>,
|
|
) -> Result<ApprovalState, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::ExternalRejectReleaseRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
release_intent_id: release_intent_id.into(),
|
|
target_environment: target_environment.into(),
|
|
comment: comment.map(|s| s.to_string()),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.policy_client()
|
|
.external_reject_release(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(convert_approval_state(resp.into_inner().state))
|
|
}
|
|
|
|
async fn get_approval_state(
|
|
&self,
|
|
access_token: &str,
|
|
organisation: &str,
|
|
project: &str,
|
|
release_intent_id: &str,
|
|
target_environment: &str,
|
|
) -> Result<ApprovalState, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::GetExternalApprovalStateRequest {
|
|
project: Some(forage_grpc::Project {
|
|
organisation: organisation.into(),
|
|
project: project.into(),
|
|
}),
|
|
release_intent_id: release_intent_id.into(),
|
|
target_environment: target_environment.into(),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.policy_client()
|
|
.get_external_approval_state(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(convert_approval_state(resp.into_inner().state))
|
|
}
|
|
|
|
async fn approve_plan_stage(
|
|
&self,
|
|
access_token: &str,
|
|
release_intent_id: &str,
|
|
stage_id: &str,
|
|
) -> Result<(), PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::ApprovePlanStageRequest {
|
|
release_intent_id: release_intent_id.into(),
|
|
stage_id: stage_id.into(),
|
|
},
|
|
)?;
|
|
self.release_client()
|
|
.approve_plan_stage(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn reject_plan_stage(
|
|
&self,
|
|
access_token: &str,
|
|
release_intent_id: &str,
|
|
stage_id: &str,
|
|
reason: Option<&str>,
|
|
) -> Result<(), PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::RejectPlanStageRequest {
|
|
release_intent_id: release_intent_id.into(),
|
|
stage_id: stage_id.into(),
|
|
reason: reason.map(|s| s.into()),
|
|
},
|
|
)?;
|
|
self.release_client()
|
|
.reject_plan_stage(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_plan_output(
|
|
&self,
|
|
access_token: &str,
|
|
release_intent_id: &str,
|
|
stage_id: &str,
|
|
) -> Result<PlanOutput, PlatformError> {
|
|
let req = platform_authed_request(
|
|
access_token,
|
|
forage_grpc::GetPlanOutputRequest {
|
|
release_intent_id: release_intent_id.into(),
|
|
stage_id: stage_id.into(),
|
|
},
|
|
)?;
|
|
let resp = self
|
|
.release_client()
|
|
.get_plan_output(req)
|
|
.await
|
|
.map_err(map_platform_status)?;
|
|
let inner = resp.into_inner();
|
|
Ok(PlanOutput {
|
|
plan_output: inner.plan_output,
|
|
status: inner.status,
|
|
outputs: inner.outputs.into_iter().map(|o| {
|
|
forage_core::platform::PlanDestinationOutput {
|
|
destination_id: o.destination_id,
|
|
destination_name: o.destination_name,
|
|
plan_output: o.plan_output,
|
|
status: o.status,
|
|
}
|
|
}).collect(),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn convert_policy_evaluation(e: forage_grpc::PolicyEvaluation) -> PolicyEvaluation {
|
|
let policy_type = match e.policy_type {
|
|
1 => "soak_time",
|
|
2 => "branch_restriction",
|
|
3 => "approval",
|
|
_ => "unknown",
|
|
};
|
|
let approval_state = e.external_approval_state.map(|s| convert_approval_state(Some(s)));
|
|
PolicyEvaluation {
|
|
policy_name: e.policy_name,
|
|
policy_type: policy_type.into(),
|
|
passed: e.passed,
|
|
reason: e.reason,
|
|
approval_state,
|
|
}
|
|
}
|
|
|
|
fn convert_approval_state(state: Option<forage_grpc::ExternalApprovalState>) -> ApprovalState {
|
|
match state {
|
|
Some(s) => ApprovalState {
|
|
required_approvals: s.required_approvals,
|
|
current_approvals: s.current_approvals,
|
|
decisions: s
|
|
.decisions
|
|
.into_iter()
|
|
.map(|d| ApprovalDecisionEntry {
|
|
user_id: d.user_id,
|
|
username: d.username,
|
|
decision: d.decision,
|
|
decided_at: d.decided_at,
|
|
comment: d.comment,
|
|
})
|
|
.collect(),
|
|
},
|
|
None => ApprovalState {
|
|
required_approvals: 0,
|
|
current_approvals: 0,
|
|
decisions: vec![],
|
|
},
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_org(id: &str, name: &str) -> forage_grpc::Organisation {
|
|
forage_grpc::Organisation {
|
|
organisation_id: id.into(),
|
|
name: name.into(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn make_artifact(slug: &str, ctx: Option<forage_grpc::ArtifactContext>) -> forage_grpc::Artifact {
|
|
forage_grpc::Artifact {
|
|
artifact_id: "a1".into(),
|
|
slug: slug.into(),
|
|
context: ctx,
|
|
created_at: "2026-01-01".into(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn convert_organisations_pairs_orgs_with_roles() {
|
|
let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")];
|
|
let roles = vec!["owner".into(), "member".into()];
|
|
|
|
let result = convert_organisations(orgs, roles);
|
|
assert_eq!(result.len(), 2);
|
|
assert_eq!(result[0].name, "alpha");
|
|
assert_eq!(result[0].role, "owner");
|
|
assert_eq!(result[1].name, "beta");
|
|
assert_eq!(result[1].role, "member");
|
|
}
|
|
|
|
#[test]
|
|
fn convert_organisations_truncates_when_roles_shorter() {
|
|
let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")];
|
|
let roles = vec!["owner".into()]; // only 1 role for 2 orgs
|
|
|
|
let result = convert_organisations(orgs, roles);
|
|
// zip truncates to shorter iterator
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].name, "alpha");
|
|
}
|
|
|
|
#[test]
|
|
fn convert_organisations_empty() {
|
|
let result = convert_organisations(vec![], vec![]);
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn convert_artifact_with_full_context() {
|
|
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
|
|
title: "My API".into(),
|
|
description: Some("A cool API".into()),
|
|
..Default::default()
|
|
}));
|
|
|
|
let result = convert_artifact(a);
|
|
assert_eq!(result.slug, "my-api");
|
|
assert_eq!(result.context.title, "My API");
|
|
assert_eq!(result.context.description.as_deref(), Some("A cool API"));
|
|
}
|
|
|
|
#[test]
|
|
fn convert_artifact_empty_description_becomes_none() {
|
|
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
|
|
title: "My API".into(),
|
|
description: Some(String::new()),
|
|
..Default::default()
|
|
}));
|
|
|
|
let result = convert_artifact(a);
|
|
assert!(result.context.description.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn convert_artifact_missing_context_uses_defaults() {
|
|
let a = make_artifact("my-api", None);
|
|
|
|
let result = convert_artifact(a);
|
|
assert_eq!(result.context.title, "");
|
|
assert!(result.context.description.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn convert_artifact_none_description_stays_none() {
|
|
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
|
|
title: "My API".into(),
|
|
description: None,
|
|
..Default::default()
|
|
}));
|
|
|
|
let result = convert_artifact(a);
|
|
assert!(result.context.description.is_none());
|
|
}
|
|
}
|