diff --git a/lib/backend-api/schema.graphql b/lib/backend-api/schema.graphql index e90fbafb14c..a8c67c869a6 100644 --- a/lib/backend-api/schema.graphql +++ b/lib/backend-api/schema.graphql @@ -72,6 +72,7 @@ type User implements Node & PackageOwner & Owner { loginMethods: [LoginMethod!]! githubUser: SocialAuth githubScopes: [String]! + githubRepositories: [GithubRepository]! } """Setup for backwards compatibility with existing frontends.""" @@ -949,12 +950,14 @@ type DeployApp implements Node & Owner { aggregateMetrics: AggregateMetrics! aliases(offset: Int, before: String, after: String, first: Int, last: Int): AppAliasConnection! secrets(offset: Int, before: String, after: String, first: Int, last: Int): SecretConnection! + databases(offset: Int, before: String, after: String, first: Int, last: Int): AppDatabaseConnection! usageMetrics(forRange: MetricRange!, variant: MetricType!): [UsageMetric]! s3Url: URL s3Credentials: S3Credentials deleted: Boolean! favicon: URL screenshot(viewportSize: AppScreenshotViewportSize, appearance: AppScreenshotAppearance): URL + deployments(offset: Int, before: String, after: String, first: Int, last: Int): AutobuildRepositoryConnection } enum DeployAppVersionsSortBy { @@ -1042,6 +1045,39 @@ type Secret implements Node { id: ID! } +type AppDatabaseConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [AppDatabaseEdge]! + + """Total number of items in the connection.""" + totalCount: Int +} + +"""A Relay edge containing a `AppDatabase` and its cursor.""" +type AppDatabaseEdge { + """The item at the end of the edge""" + node: AppDatabase + + """A cursor for use in pagination""" + cursor: String! +} + +type AppDatabase implements Node { + createdAt: DateTime! + updatedAt: DateTime! + deletedAt: DateTime + name: String! + username: String! + app: DeployApp! + + """The ID of the object""" + id: ID! + host: String! +} + type UsageMetric { variant: MetricType! value: Float! @@ -1108,6 +1144,59 @@ enum AppScreenshotAppearance { DARK } +type AutobuildRepositoryConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [AutobuildRepositoryEdge]! + + """Total number of items in the connection.""" + totalCount: Int +} + +"""A Relay edge containing a `AutobuildRepository` and its cursor.""" +type AutobuildRepositoryEdge { + """The item at the end of the edge""" + node: AutobuildRepository + + """A cursor for use in pagination""" + cursor: String! +} + +type AutobuildRepository implements Node { + """The ID of the object""" + id: ID! + user: User! + name: String! + namespace: String! + app: DeployApp + createdAt: DateTime! + updatedAt: DateTime! + appName: String + buildId: UUID! + repoUrl: String! + status: StatusEnum! + appVersion: DeployAppVersion + logUrl: String +} + +""" +Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects +in fields, resolvers and input. +""" +scalar UUID + +enum StatusEnum { + SUCCESS + WORKING + FAILURE + QUEUED + TIMEOUT + INTERNAL_ERROR + CANCELLED +} + type LogConnection { """Pagination data for this connection.""" pageInfo: PageInfo! @@ -1136,7 +1225,7 @@ type AppVersionVolume { name: String! s3Url: String! mountPaths: [AppVersionVolumeMountPath]! - size: Int + size: BigInt usedSize: BigInt } @@ -2066,6 +2155,12 @@ type SocialAuth implements Node { username: String! } +type GithubRepository { + url: String! + name: String! + namespace: String! +} + type Signature { id: ID! publicKey: PublicKey! @@ -2360,6 +2455,7 @@ type Query { getSecretValue(id: ID!): String getAppTemplate(slug: String!): AppTemplate getAppTemplateCategories(offset: Int, before: String, after: String, first: Int, last: Int): AppTemplateCategoryConnection + getBuildKinds(offset: Int, before: String, after: String, first: Int, last: Int): BuildKindConnection! viewer: User getUser(username: String!): User getPasswordResetToken(token: String!): GetPasswordResetToken @@ -2596,6 +2692,36 @@ type AppTemplateCategoryEdge { cursor: String! } +type BuildKindConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [BuildKindEdge]! + + """Total number of items in the connection.""" + totalCount: Int +} + +"""A Relay edge containing a `BuildKind` and its cursor.""" +type BuildKindEdge { + """The item at the end of the edge""" + node: BuildKind + + """A cursor for use in pagination""" + cursor: String! +} + +type BuildKind implements Node { + name: String! + setupDb: Boolean! + buildCmd: String! + installCmd: String! + + """The ID of the object""" + id: ID! +} + type GetPasswordResetToken { valid: Boolean! user: User @@ -3049,6 +3175,15 @@ type Mutation { """Redeploy the active version of an app.""" rotateS3SecretsForApp(input: RotateS3SecretsForAppInput!): RotateS3SecretsForAppPayload + + """Delete a database for an app.""" + rotateCredentialsForAppDb(input: RotateCredentialsForAppDBInput!): RotateCredentialsForAppDBPayload + + """Create a new database for an app.""" + createAppDb(input: CreateAppDBInput!): CreateAppDBPayload + + """Delete a database for an app.""" + deleteAppDb(input: DeleteAppDBInput!): DeleteAppDBPayload tokenAuth(input: ObtainJSONWebTokenInput!): ObtainJSONWebTokenPayload generateDeployToken(input: GenerateDeployTokenInput!): GenerateDeployTokenPayload verifyAccessToken(token: String): Verify @@ -3532,6 +3667,47 @@ input RotateS3SecretsForAppInput { clientMutationId: String } +"""Delete a database for an app.""" +type RotateCredentialsForAppDBPayload { + database: AppDatabase! + password: String! + clientMutationId: String +} + +input RotateCredentialsForAppDBInput { + """App Database ID""" + id: ID! + clientMutationId: String +} + +"""Create a new database for an app.""" +type CreateAppDBPayload { + database: AppDatabase! + password: String! + clientMutationId: String +} + +input CreateAppDBInput { + """App ID""" + id: ID! + + """Database name""" + name: String! + clientMutationId: String +} + +"""Delete a database for an app.""" +type DeleteAppDBPayload { + success: Boolean! + clientMutationId: String +} + +input DeleteAppDBInput { + """App Database ID""" + id: ID! + clientMutationId: String +} + type ObtainJSONWebTokenPayload { payload: GenericScalar! refreshExpiresIn: Int! @@ -4382,6 +4558,9 @@ type Subscription { ): Log! waitOnRepoCreation(repoId: ID!): Boolean! appIsPublishedFromRepo(repoId: ID!): DeployAppVersion! + publishAppFromRepoAutobuild(repoUrl: String!, appName: String!, owner: String, buildCmd: String, installCmd: String, databaseName: String, secrets: [SecretInput]): AutobuildLog + fetchBuildLogs(buildId: String!): String + autobuildConfigForRepo(repoUrl: String!): BuildConfig packageVersionCreated(publishedBy: ID, ownerId: ID): PackageVersion! """Subscribe to package version ready""" @@ -4389,6 +4568,32 @@ type Subscription { userNotificationCreated(userId: ID!): UserNotificationCreated! } +"""Log entry for Deploying app from github repo""" +type AutobuildLog { + """Kind of log message.""" + kind: AutoBuildDeployAppLogKind! + + """Log message""" + message: String + appVersion: DeployAppVersion + + """The database password, if DB was setup.""" + dbPassword: String +} + +enum AutoBuildDeployAppLogKind { + LOG + COMPLETE +} + +"""Log entry for Deploying app from github repo""" +type BuildConfig { + buildCmd: String! + installCmd: String! + setupDb: Boolean! + presetName: String! +} + type PackageVersionReadyResponse { state: PackageVersionState! packageVersion: PackageVersion! diff --git a/lib/backend-api/src/query.rs b/lib/backend-api/src/query.rs index fe888837882..9032c788b90 100644 --- a/lib/backend-api/src/query.rs +++ b/lib/backend-api/src/query.rs @@ -1009,6 +1009,41 @@ pub async fn get_deploy_app_versions( Ok(versions) } +/// Get app deployments for an app. +pub async fn app_deployments( + client: &WasmerClient, + vars: types::GetAppDeploymentsVariables, +) -> Result, anyhow::Error> { + let res = client + .run_graphql_strict(types::GetAppDeployments::build(vars)) + .await?; + let builds = res + .get_deploy_app + .and_then(|x| x.deployments) + .context("no data returned")? + .edges + .into_iter() + .flatten() + .filter_map(|x| x.node) + .collect(); + + Ok(builds) +} + +/// Get an app deployment by ID. +pub async fn app_deployment( + client: &WasmerClient, + id: String, +) -> Result { + let node = get_node(client, id.clone()) + .await? + .with_context(|| format!("app deployment with id '{}' not found", id))?; + match node { + types::Node::AutobuildRepository(x) => Ok(*x), + _ => anyhow::bail!("invalid node type returned"), + } +} + /// Load all versions of an app. /// /// Will paginate through all versions and return them in a single list. diff --git a/lib/backend-api/src/types.rs b/lib/backend-api/src/types.rs index 14bbf5f6ed2..92221d71ed2 100644 --- a/lib/backend-api/src/types.rs +++ b/lib/backend-api/src/types.rs @@ -844,7 +844,7 @@ mod queries { #[derive(serde::Serialize, cynic::QueryFragment, Debug)] pub struct AppVersionVolume { pub name: String, - pub size: Option, + pub size: Option, pub used_size: Option, } @@ -1184,6 +1184,81 @@ mod queries { pub app: DeployApp, } + #[derive(cynic::QueryVariables, Debug)] + pub struct GetAppDeploymentsVariables { + pub after: Option, + pub first: Option, + pub name: String, + pub offset: Option, + pub owner: String, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "Query", variables = "GetAppDeploymentsVariables")] + pub struct GetAppDeployments { + #[arguments(owner: $owner, name: $name)] + pub get_deploy_app: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "DeployApp", variables = "GetAppDeploymentsVariables")] + pub struct DeployAppDeployments { + // FIXME: add $offset, $after, currently causes an error from the backend + // #[arguments(first: $first, after: $after, offset: $offset)] + pub deployments: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + pub struct AutobuildRepositoryConnection { + pub page_info: PageInfo, + pub edges: Vec>, + } + + #[derive(cynic::QueryFragment, Debug)] + pub struct AutobuildRepositoryEdge { + pub node: Option, + } + + #[derive(cynic::QueryFragment, serde::Serialize, Debug)] + pub struct AutobuildRepository { + pub id: cynic::Id, + pub build_id: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + pub status: StatusEnum, + pub log_url: Option, + pub repo_url: String, + } + + #[derive(cynic::Enum, Clone, Copy, Debug)] + pub enum StatusEnum { + Success, + Working, + Failure, + Queued, + Timeout, + InternalError, + Cancelled, + } + + impl StatusEnum { + pub fn as_str(&self) -> &'static str { + match self { + Self::Success => "success", + Self::Working => "working", + Self::Failure => "failure", + Self::Queued => "queued", + Self::Timeout => "timeout", + Self::InternalError => "internal_error", + Self::Cancelled => "cancelled", + } + } + } + + #[derive(cynic::Scalar, Debug, Clone)] + #[cynic(graphql_type = "UUID")] + pub struct Uuid(pub String); + #[derive(cynic::QueryVariables, Debug)] pub struct PublishDeployAppVars { pub config: String, @@ -2098,6 +2173,7 @@ mod queries { pub enum Node { DeployApp(Box), DeployAppVersion(Box), + AutobuildRepository(Box), #[cynic(fallback)] Unknown, } diff --git a/lib/cli/src/commands/app/deploy.rs b/lib/cli/src/commands/app/deploy.rs index fda80cb8009..72c02a8ab6d 100644 --- a/lib/cli/src/commands/app/deploy.rs +++ b/lib/cli/src/commands/app/deploy.rs @@ -587,7 +587,7 @@ impl AsyncCliCommand for CmdAppDeploy { wait_app(&client, opts.clone(), app_version.clone(), self.quiet).await?; - if self.fmt.format == crate::utils::render::ItemFormat::Json { + if self.fmt.format == Some(crate::utils::render::ItemFormat::Json) { println!("{}", serde_json::to_string_pretty(&app_version)?); } diff --git a/lib/cli/src/commands/app/deployments/get.rs b/lib/cli/src/commands/app/deployments/get.rs new file mode 100644 index 00000000000..07ccb4150ea --- /dev/null +++ b/lib/cli/src/commands/app/deployments/get.rs @@ -0,0 +1,29 @@ +//! Get deployments for an app. + +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts}; + +/// Get the volumes of an app. +#[derive(clap::Parser, Debug)] +pub struct CmdAppDeploymentGet { + #[clap(flatten)] + fmt: ItemFormatOpts, + + #[clap(flatten)] + env: WasmerEnv, + + /// ID of the deployment. + id: String, +} + +#[async_trait::async_trait] +impl AsyncCliCommand for CmdAppDeploymentGet { + type Output = (); + + async fn run_async(mut self) -> Result<(), anyhow::Error> { + let client = self.env.client()?; + let item = wasmer_api::query::app_deployment(&client, self.id).await?; + + println!("{}", self.fmt.get().render(&item)); + Ok(()) + } +} diff --git a/lib/cli/src/commands/app/deployments/list.rs b/lib/cli/src/commands/app/deployments/list.rs new file mode 100644 index 00000000000..e8e118568c7 --- /dev/null +++ b/lib/cli/src/commands/app/deployments/list.rs @@ -0,0 +1,50 @@ +//! List volumes tied to an edge app. + +use super::super::util::AppIdentOpts; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ListFormatOpts}; + +/// List the volumes of an app. +#[derive(clap::Parser, Debug)] +pub struct CmdAppDeploymentList { + #[clap(flatten)] + fmt: ListFormatOpts, + + #[clap(flatten)] + env: WasmerEnv, + + #[clap(flatten)] + ident: AppIdentOpts, + + #[clap(long)] + offset: Option, + + #[clap(long)] + limit: Option, +} + +#[async_trait::async_trait] +impl AsyncCliCommand for CmdAppDeploymentList { + type Output = (); + + async fn run_async(self) -> Result<(), anyhow::Error> { + let client = self.env.client()?; + + let (_ident, app) = self.ident.load_app(&client).await?; + let vars = wasmer_api::types::GetAppDeploymentsVariables { + after: None, + first: self.limit.map(|x| x as i32), + name: app.name.clone(), + offset: self.offset.map(|x| x as i32), + owner: app.owner.global_name, + }; + let items = wasmer_api::query::app_deployments(&client, vars).await?; + + if items.is_empty() { + eprintln!("App {} has no deployments!", app.name); + } else { + println!("{}", self.fmt.format.render(&items)); + } + + Ok(()) + } +} diff --git a/lib/cli/src/commands/app/deployments/logs.rs b/lib/cli/src/commands/app/deployments/logs.rs new file mode 100644 index 00000000000..426cb3b71a9 --- /dev/null +++ b/lib/cli/src/commands/app/deployments/logs.rs @@ -0,0 +1,51 @@ +//! Get logs for an app deployment. + +use std::io::Write; + +use anyhow::Context; +use futures::stream::TryStreamExt; + +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts}; + +/// Get logs for an app deployment. +#[derive(clap::Parser, Debug)] +pub struct CmdAppDeploymentLogs { + #[clap(flatten)] + fmt: ItemFormatOpts, + + #[clap(flatten)] + env: WasmerEnv, + + /// ID of the deployment. + id: String, +} + +#[async_trait::async_trait] +impl AsyncCliCommand for CmdAppDeploymentLogs { + type Output = (); + + async fn run_async(mut self) -> Result<(), anyhow::Error> { + let client = self.env.client()?; + let item = wasmer_api::query::app_deployment(&client, self.id).await?; + + let url = item + .log_url + .context("This deployment does not have logs available")?; + + let mut writer = std::io::BufWriter::new(std::io::stdout()); + + let mut stream = reqwest::Client::new() + .get(url) + .send() + .await? + .error_for_status()? + .bytes_stream(); + + while let Some(chunk) = stream.try_next().await? { + writer.write_all(&chunk)?; + writer.flush()?; + } + + Ok(()) + } +} diff --git a/lib/cli/src/commands/app/deployments/mod.rs b/lib/cli/src/commands/app/deployments/mod.rs new file mode 100644 index 00000000000..eeb8b7f651c --- /dev/null +++ b/lib/cli/src/commands/app/deployments/mod.rs @@ -0,0 +1,26 @@ +use crate::commands::AsyncCliCommand; + +pub mod get; +pub mod list; +pub mod logs; + +/// App volume management. +#[derive(Debug, clap::Parser)] +pub enum CmdAppDeployment { + List(list::CmdAppDeploymentList), + Get(get::CmdAppDeploymentGet), + Logs(logs::CmdAppDeploymentLogs), +} + +#[async_trait::async_trait] +impl AsyncCliCommand for CmdAppDeployment { + type Output = (); + + async fn run_async(self) -> Result { + match self { + Self::List(c) => c.run_async().await, + Self::Get(c) => c.run_async().await, + Self::Logs(c) => c.run_async().await, + } + } +} diff --git a/lib/cli/src/commands/app/get.rs b/lib/cli/src/commands/app/get.rs index 73b4d9de94f..12143139a31 100644 --- a/lib/cli/src/commands/app/get.rs +++ b/lib/cli/src/commands/app/get.rs @@ -4,7 +4,9 @@ use wasmer_api::types::DeployApp; use super::util::AppIdentOpts; -use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts}; +use crate::{ + commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts, utils::render::ItemFormat, +}; /// Retrieve detailed informations about an app #[derive(clap::Parser, Debug)] @@ -27,7 +29,10 @@ impl AsyncCliCommand for CmdAppGet { let client = self.env.client()?; let (_ident, app) = self.ident.load_app(&client).await?; - println!("{}", self.fmt.format.render(&app)); + println!( + "{}", + self.fmt.get_with_default(ItemFormat::Yaml).render(&app) + ); Ok(app) } diff --git a/lib/cli/src/commands/app/mod.rs b/lib/cli/src/commands/app/mod.rs index 7a461a06c83..0267c2edfa5 100644 --- a/lib/cli/src/commands/app/mod.rs +++ b/lib/cli/src/commands/app/mod.rs @@ -3,6 +3,7 @@ pub mod create; pub mod delete; pub mod deploy; +mod deployments; pub mod get; pub mod info; pub mod list; @@ -36,6 +37,8 @@ pub enum CmdApp { Region(regions::CmdAppRegions), #[clap(subcommand, alias = "volumes")] Volume(volumes::CmdAppVolumes), + #[clap(subcommand, alias = "deployments")] + Deployment(deployments::CmdAppDeployment), } #[async_trait::async_trait] @@ -65,6 +68,7 @@ impl AsyncCliCommand for CmdApp { Self::Secret(cmd) => cmd.run_async().await, Self::Region(cmd) => cmd.run_async().await, Self::Volume(cmd) => cmd.run_async().await, + Self::Deployment(cmd) => cmd.run_async().await, } } } diff --git a/lib/cli/src/commands/app/version/get.rs b/lib/cli/src/commands/app/version/get.rs index 83d80edffa2..aa199644c10 100644 --- a/lib/cli/src/commands/app/version/get.rs +++ b/lib/cli/src/commands/app/version/get.rs @@ -4,6 +4,7 @@ use crate::{ commands::{app::util::AppIdentOpts, AsyncCliCommand}, config::WasmerEnv, opts::ItemFormatOpts, + utils::render::ItemFormat, }; /// Show information for a specific app version. @@ -41,7 +42,10 @@ impl AsyncCliCommand for CmdAppVersionGet { .await? .with_context(|| format!("Could not find app version '{}'", self.name))?; - println!("{}", self.fmt.format.render(&version)); + println!( + "{}", + self.fmt.get_with_default(ItemFormat::Yaml).render(&version) + ); Ok(()) } diff --git a/lib/cli/src/commands/namespace/create.rs b/lib/cli/src/commands/namespace/create.rs index adc6b3017f4..4770eaaf381 100644 --- a/lib/cli/src/commands/namespace/create.rs +++ b/lib/cli/src/commands/namespace/create.rs @@ -30,7 +30,7 @@ impl AsyncCliCommand for CmdNamespaceCreate { }; let namespace = wasmer_api::query::create_namespace(&client, vars).await?; - println!("{}", self.fmt.format.render(&namespace)); + println!("{}", self.fmt.get().render(&namespace)); Ok(()) } diff --git a/lib/cli/src/commands/namespace/get.rs b/lib/cli/src/commands/namespace/get.rs index 48138de8309..9ca3a0cfcc7 100644 --- a/lib/cli/src/commands/namespace/get.rs +++ b/lib/cli/src/commands/namespace/get.rs @@ -26,7 +26,7 @@ impl AsyncCliCommand for CmdNamespaceGet { .await? .context("namespace not found")?; - println!("{}", self.fmt.format.render(&namespace)); + println!("{}", self.fmt.get().render(&namespace)); Ok(()) } diff --git a/lib/cli/src/opts.rs b/lib/cli/src/opts.rs index cc9844a2197..8b0a983c085 100644 --- a/lib/cli/src/opts.rs +++ b/lib/cli/src/opts.rs @@ -1,9 +1,30 @@ +use crate::utils::render::ItemFormat; + /// Formatting options for a single item. #[derive(clap::Parser, Debug, Default)] pub struct ItemFormatOpts { /// Output format. (yaml, json, table) - #[clap(short = 'f', long, default_value = "yaml")] - pub format: crate::utils::render::ItemFormat, + /// + /// This value is optional instead of using a default value to allow code + /// to distinguish between the user not specifying a value and a generic + /// default. + /// + /// Code should usually use [`Self::get`] to use the same default format. + #[clap(short = 'f', long)] + pub format: Option, +} + +impl ItemFormatOpts { + /// Get the output format, defaulting to `ItemFormat::Yaml`. + pub fn get(&self) -> crate::utils::render::ItemFormat { + self.format + .unwrap_or(crate::utils::render::ItemFormat::Table) + } + + /// Get the output format, defaulting to the given value if not specified. + pub fn get_with_default(&self, default: ItemFormat) -> crate::utils::render::ItemFormat { + self.format.unwrap_or(default) + } } /// Formatting options for a single item. diff --git a/lib/cli/src/types.rs b/lib/cli/src/types.rs index 0b391d036d1..4e8046ddea0 100644 --- a/lib/cli/src/types.rs +++ b/lib/cli/src/types.rs @@ -196,3 +196,32 @@ fn format_disk_size_opt(value: Option) -> String { "n/a".to_string() } } + +impl CliRender for wasmer_api::types::AutobuildRepository { + fn render_item_table(&self) -> String { + let mut table = Table::new(); + table.add_rows([ + vec!["Id".to_string(), self.id.clone().into_inner()], + vec!["Status".to_string(), self.status.as_str().to_string()], + vec!["Created at".to_string(), self.created_at.0.clone()], + ]); + table.to_string() + } + + fn render_list_table(items: &[Self]) -> String { + let mut table = Table::new(); + table.set_header(vec![ + "Id".to_string(), + "Status".to_string(), + "Created at".to_string(), + ]); + table.add_rows(items.iter().map(|item| { + vec![ + item.id.clone().into_inner(), + item.status.as_str().to_string(), + item.created_at.0.clone(), + ] + })); + table.to_string() + } +}