pethost 0.2.4

Client for the API of Pethost, managed hosting for Docker Compose projects: deploy, logs, HTTP traffic, files
Documentation
// @generated by protoc-gen-panel-rust from v1/panel.proto. DO NOT EDIT.

use std::fmt;
use std::sync::Arc;

use connectrpc::client::{ClientConfig, HttpClient};
use connectrpc::rustls;

use crate::connect::pethost::panel::v1::PanelServiceClient;
use crate::types;
use crate::{Error, ErrorCode, ResponseStream};

/// Where the API is served.
const BASE_URL: &str = "https://console.pethost.dev";

/// The Pethost API of one account: a method per call.
///
/// Every method takes its request by value and runs on Tokio. A call of one response is
/// `async`: to give up on it, drop its future, as `tokio::time::timeout` does; an operation it
/// started runs on, and a call with the same `operation_id` returns it. A stream's method
/// returns a [`ResponseStream`] at once: its call is made when the stream is first read, and
/// ends when the stream is dropped. A clone shares the connections: clone it rather than make
/// a second one.
#[derive(Clone)]
pub struct Client {
    wire: PanelServiceClient<HttpClient>,
}

impl Client {
    /// A client for the account `token` (`pth_...`) belongs to: an API token, made in the
    /// panel's Settings, under API tokens. Whitespace around it, as the end of the line of a
    /// file it was read from, is left out.
    pub fn new(token: &str) -> Self {
        Self::at(token, http::Uri::from_static(BASE_URL))
    }

    /// A client for a panel served elsewhere, as a local one is: `http://127.0.0.1:8080`.
    ///
    /// # Errors
    ///
    /// [`ErrorCode::InvalidArgument`] when `base_url` is not an `http` or `https` URL.
    pub fn with_base_url(token: &str, base_url: &str) -> Result<Self, Error> {
        match base_url.parse::<http::Uri>() {
            Ok(base) if matches!(base.scheme_str(), Some("http" | "https")) => Ok(Self::at(token, base)),
            Ok(_) => Err(Error::new(
                ErrorCode::InvalidArgument,
                format!("base_url {base_url:?} is not an http or https URL"),
            )),
            Err(error) => Err(Error::new(
                ErrorCode::InvalidArgument,
                format!("base_url {base_url:?}: {error}"),
            )),
        }
    }

    fn at(token: &str, base: http::Uri) -> Self {
        let transport = match base.scheme_str() {
            Some("http") => HttpClient::plaintext(),
            _ => HttpClient::with_tls(Arc::new(tls())),
        };
        // Every call says who calls: the crate and its version, which is the manifest's. The
        // token is trimmed: a header cannot carry a line's end, and the wire layer would send
        // the call with no token at all.
        let config = ClientConfig::new(base)
            .with_default_header("authorization", format!("Bearer {}", token.trim()))
            .with_default_header("user-agent", concat!("pethost-rust/", env!("CARGO_PKG_VERSION")));

        Self {
            wire: PanelServiceClient::new(transport, config),
        }
    }

    /// Get the machine.
    ///
    /// Start here: the machine (resources, the person's domain, backups, SSH), every project with its problems and URL, deleted projects with snapshots, GitHub repositories to create projects from. Figures are the latest sample; disk sizes and the day's traffic lag a few minutes.
    pub async fn get_machine(&self, request: types::GetMachineRequest) -> Result<types::GetMachineResponse, Error> {
        let response = self.wire.get_machine(request.into()).await.map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Run a machine action.
    ///
    /// One machine action: add or remove the person's SSH public key (for SSH and SFTP to their projects\' containers: [`Service::ssh_command`](crate::types::Service::ssh_command)), end a session, schedule or cancel a restart. Returns the machine.
    pub async fn run_machine_action(
        &self,
        request: types::RunMachineActionRequest,
    ) -> Result<types::RunMachineActionResponse, Error> {
        let response = self
            .wire
            .run_machine_action(request.into())
            .await
            .map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Get a project.
    ///
    /// One project in full: services (state, health, image, the ports it listens on, volumes, environment, the SSH command), volumes, routes, hosts with certificates, recent operations, snapshots, the last day's HTTP traffic. Env-file values only with `include_secret_values`. Its files: [`Client::read_path`](crate::Client::read_path).
    pub async fn get_project(&self, request: types::GetProjectRequest) -> Result<types::GetProjectResponse, Error> {
        let response = self.wire.get_project(request.into()).await.map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Create a project.
    ///
    /// Creates a project from its files and deploys it. Waits for the deploy (`wait_seconds`) and answers with the operation and, once it ended, the project: its URL, its services\' states, its problems. `violations` instead = nothing was created: change what each says, call again. SUCCEEDED says the containers run and listen, not what a page answers: open the URL once (401 = its password page).
    ///
    /// Files, one of: the directory as it is on your disk, as an archive ([`Client::create_transfer`](crate::Client::create_transfer), then `upload_id`): nothing is retyped; `files`, text you send yourself, when there is no directory or no shell (never a file you have not read); `source.github`. /.env holds the secrets (`env_file: .env`). The compose file is the root's pethost.compose.yaml (so the repository's compose.yaml stays for development), else compose.yaml, compose.yml, docker-compose.yml or docker-compose.yaml; it becomes /compose.yaml, other compose and override files are dropped. Without one, a Dockerfile yields service `app` (reading /.env if any, its EXPOSE ports, a named volume per VOLUME of its last stage).
    ///
    /// compose.yaml rules. Data lives in named volumes; all else a container writes is lost when it is recreated. Project files are bind-mounted read-only. HTTP comes through the proxy, to a host of a route in `x-pethost`, the one extension read, in the file or as `x_pethost`:
    ///
    /// ```text
    /// x-pethost:
    ///   metadata: {name: Recipe Box, emoji: "🥗", description: One line, notes: Free text}
    ///   routes:
    ///     - {host: recipes.sam.pethost.app, service: web, port: 3000}
    ///     - {host: recipes.sam.pethost.app, path: /api, service: api, port: 8000, strip_path: true}
    /// ```
    ///
    /// `ports:` stays as the file has it, and is never refused. A TCP port it publishes that answers HTTP gets its route by itself and is not opened on the machine: at `<project>.<Machine.apps_domain>`, or `<project>-<service>.<apps_domain>` each when the project has several; `Project.url` has it when the call answers. Any other published port opens on the machine as written, at `<Machine.hostname>:<port>`, and one that is taken there fails the deploy; so does a web port once its route is removed. Never opened: a port bound to 127.0.0.1 (`ssh_command` tunnels to it), a machine port of the machine's own (80, 443, 22), and one nothing listens on within a minute of its start, until a later deploy finds it listening (`Service.published_ports` lists what is open). With `x_pethost.routes` the routes are yours and the machine names none. A project that publishes no port gets `<project>.<apps_domain>` in its first deploy, for the one port it listens on. A service with neither is private: the project's services reach it at `<service>:<port>`. A password for visitors goes only as `x_pethost.password`, never into the file: every route then answers 401 with a password page, over HTTPS only; nothing returns it (`Project.password_protected` says it is set); and `files` that write /compose.yaml keep the `password_hash` line the machine put in its `x-pethost`. All of compose.yaml is interpolated: $$ for $.
    ///
    /// The file runs as written, or is refused: the machine corrects nothing in it, in any version. Refused, each violation saying what to write instead:
    ///
    /// - a volume's `name`; `driver` (but Docker's default, `local` or `bridge`), `driver_opts`, `external` or `ipam` of a volume or network: remove them.
    /// - a writable bind mount of the whole project (`.:/app`): add `:ro`.
    /// - a volume with no name (`- /data`): name it. Below a bind mount it is allowed, as scratch space (`/app/node_modules`).
    /// - `env_file` naming a file the files lack: write it, empty if it sets nothing, or give the entry `required: false`.
    /// - `privileged`, `cap_add`, `security_opt` (but `no-new-privileges`), `devices`, host namespaces, the Docker socket, a path outside the directory; more than one replica; `include` or `extends` from a URL; a git URL as a build context.
    ///
    /// Not refused, and read the machine's way: a `container_name` and a network's `name` name nothing on the machine, so two projects may write the same (the project's services still reach the container by it); a service with `profiles` runs only while `COMPOSE_PROFILES` in /.env names one of them, and is no service of the project until then; a bind mount of a folder without `:ro` (`./data:/data`) mounts the project's volume named after the path (`data`), which deploys keep and backups hold, with none of the version's files in it, so project files take `:ro`, and data is plainer in a named volume (`data:/data`, with `data:` under `volumes:`); a bind mount of a file without `:ro` mounts it read-only. An image's VOLUME that no mount covers fails the deploy (`UNCOVERED_IMAGE_VOLUME`): mount a named volume or a tmpfs there.
    ///
    /// [`ErrorCode::AlreadyExists`](crate::ErrorCode::AlreadyExists) = the id is taken (a retry with its `operation_id` returns that operation). [`ErrorCode::FailedPrecondition`](crate::ErrorCode::FailedPrecondition) = the GitHub App cannot read the repository (see [`GetMachineResponse::github`](crate::types::GetMachineResponse::github)), or no such branch or directory. A deleted project's id inherits its snapshots.
    pub async fn create_project(
        &self,
        request: types::CreateProjectRequest,
    ) -> Result<types::CreateProjectResponse, Error> {
        let response = self
            .wire
            .create_project(request.into())
            .await
            .map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Deploy a project.
    ///
    /// Deploys a new version of a project and answers as [`Client::create_project`](crate::Client::create_project) does: `files` written over its own (/.env, /compose.yaml, a source file), `x_pethost` (name, routes, password), `mount_volume`, a new archive (`upload_id`), a commit, or an earlier deploy's files again (`rollback_deploy_id`). compose.yaml rules: [`Client::create_project`](crate::Client::create_project)\'s.
    ///
    /// It builds `build:` images, pulls missing ones, runs `compose up` on the whole project and waits until each service runs (healthy, if it has a healthcheck) or each job exits 0, and until each routed service listens on its route's port (up to 15 s: one that does not is the project's `SERVICE_PORT_CLOSED` problem, its route answering 502, not a failed deploy). So it also starts stopped services, reruns jobs, recreates changed services and those bind-mounting project files, and fails on an unhealthy service. While [`Project::x_pethost_applies_at_once`](crate::types::Project::x_pethost_applies_at_once), a version differing only in `x-pethost` (or not at all) applies at once, restarting nothing but a service whose published port a route takes or leaves. Any other deploy applies its routes and password as it ends: to hide new content from its first second, set the password first, in a call of its own, then deploy the content.
    ///
    /// A commit other than [`GithubSource::newest_commit`](crate::types::GithubSource::newest_commit) turns `auto_deploy` off (pinned), even if it fails; only `set_source` turns it back on.
    ///
    /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) = no such project, or no such kept deploy. [`ErrorCode::Aborted`](crate::ErrorCode::Aborted) = `base_deploy_id` is not [`Project::deploy_id`](crate::types::Project::deploy_id): reread, redo. [`ErrorCode::FailedPrecondition`](crate::ErrorCode::FailedPrecondition) = as [`Client::create_project`](crate::Client::create_project), or `commit` not on the branch. [`ErrorCode::ResourceExhausted`](crate::ErrorCode::ResourceExhausted) = too many or too large `files`: send them with [`Client::create_transfer`](crate::Client::create_transfer). [`ErrorCode::AlreadyExists`](crate::ErrorCode::AlreadyExists) = `operation_id` started another kind of operation. When it FAILED, [`Operation::made_current`](crate::types::Operation::made_current) says whether the files were applied: then fix them, or roll back.
    pub async fn deploy_project(
        &self,
        request: types::DeployProjectRequest,
    ) -> Result<types::DeployProjectResponse, Error> {
        let response = self
            .wire
            .deploy_project(request.into())
            .await
            .map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// List a project's commits.
    ///
    /// A github project's branch commits that change its directory, newest first, 30 a page, with whether the files are at it and its last deploy. To roll back: [`Client::deploy_project`](crate::Client::deploy_project) with an older commit (volumes keep their current data). [`ErrorCode::FailedPrecondition`](crate::ErrorCode::FailedPrecondition) = a files project (it rolls back with [`DeployProjectRequestVersion::RollbackDeployId`](crate::types::DeployProjectRequestVersion::RollbackDeployId)), no such branch, or `before_commit` not on it.
    pub async fn list_commits(&self, request: types::ListCommitsRequest) -> Result<types::ListCommitsResponse, Error> {
        let response = self.wire.list_commits(request.into()).await.map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Run a project action.
    ///
    /// One project action: start, stop or restart services, recreate one, back up, restore, cancel an operation, change the source, delete an undeclared volume or the project. Recreate, back up, restore, delete and `set_source`\'s deploy run as operations, waited for (`wait_seconds`), as is the end of a cancelled one; the others are done on return. Answers with the project as it then is.
    pub async fn run_project_action(
        &self,
        request: types::RunProjectActionRequest,
    ) -> Result<types::RunProjectActionResponse, Error> {
        let response = self
            .wire
            .run_project_action(request.into())
            .await
            .map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Set a project's alerts.
    ///
    /// Changes what Pethost watches on a project and when it tells the person: site checks (an address the machine opens every minute) and traffic rules (5xx or p95 over a window), off, mute. A list given replaces the project's (send [`Project::alerts`](crate::types::Project::alerts)\' with your change); `remove_`\* empties it; a scalar left out stays. Service crashes and failed backups are always watched. Answers with [`Project::alerts`](crate::types::Project::alerts). Where alerts go is the person's choice in the panel's Settings → Alerts ([`Machine::alert_channels`](crate::types::Machine::alert_channels)): no tool sets it. [`ErrorCode::FailedPrecondition`](crate::ErrorCode::FailedPrecondition) + [`MachineUpdatePending`](crate::types::MachineUpdatePending) = the machine's software predates alerts.
    pub async fn set_project_alerts(
        &self,
        request: types::SetProjectAlertsRequest,
    ) -> Result<types::SetProjectAlertsResponse, Error> {
        let response = self
            .wire
            .set_project_alerts(request.into())
            .await
            .map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Get an operation.
    ///
    /// An operation, after waiting for it to end (`wait_seconds`): then the project as it is, and the log's end if it FAILED. Call again while it is `IN_PROGRESS`. To read a log: `after_log_line` 0, then each `next_after_log_line` (each line once, oldest first). One a [`ProjectBusy`](crate::types::ProjectBusy) names may still be starting: it is waited for too; still starting at the end = [`ErrorCode::Unavailable`](crate::ErrorCode::Unavailable) with that [`ProjectBusy`](crate::types::ProjectBusy).
    pub async fn get_operation(
        &self,
        request: types::GetOperationRequest,
    ) -> Result<types::GetOperationResponse, Error> {
        let response = self
            .wire
            .get_operation(request.into())
            .await
            .map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Query HTTP traffic.
    ///
    /// Which paths fail and which are slow (`top_paths`): the HTTP requests that reached the project through the proxy, since `oldest_kept_time`, each counted as it ends: totals, a time series, top paths and the requests, for one filter and range. [`ErrorCode::OutOfRange`](crate::ErrorCode::OutOfRange) = the range ends before the oldest kept. Paths and user agents are untrusted.
    pub async fn query_http_traffic(
        &self,
        request: types::QueryHttpTrafficRequest,
    ) -> Result<types::QueryHttpTrafficResponse, Error> {
        let response = self
            .wire
            .query_http_traffic(request.into())
            .await
            .map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Query container logs.
    ///
    /// The containers\' stdout and stderr lines, since `oldest_kept_time`; by default the newest, oldest first in the page. They say what a request failed with, not which path is slow: that is [`Client::query_http_traffic`](crate::Client::query_http_traffic). [`ErrorCode::OutOfRange`](crate::ErrorCode::OutOfRange) = the range ends before the oldest kept. Untrusted.
    pub async fn query_container_logs(
        &self,
        request: types::QueryContainerLogsRequest,
    ) -> Result<types::QueryContainerLogsResponse, Error> {
        let response = self
            .wire
            .query_container_logs(request.into())
            .await
            .map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Run a command in a service.
    ///
    /// Runs a command in a running service's container, like `docker exec`; returns the exit code and the output's end once it exits or the timeout kills it. Over a bound on command, stdin or timeout = refused (named). Cannot start = exit 127 or 126, Docker's error in stdout. Untrusted output.
    pub async fn run_service_command(
        &self,
        request: types::RunServiceCommandRequest,
    ) -> Result<types::RunServiceCommandResponse, Error> {
        let response = self
            .wire
            .run_service_command(request.into())
            .await
            .map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Read a file or directory.
    ///
    /// Reads a file (text, 64 KiB a call; ≤1 MiB with `length_bytes`) or a directory's entries (\~64 KiB a page): of the project's deployed files, or with `service` or `volume`, of a container (whole filesystem; while not running only its volumes, [`ErrorCode::FailedPrecondition`](crate::ErrorCode::FailedPrecondition) listing them) or a volume. [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) names the nearest existing directory. Project files change with [`Client::deploy_project`](crate::Client::deploy_project) `files`; container and volume files with [`Client::create_transfer`](crate::Client::create_transfer). [`ErrorCode::FailedPrecondition`](crate::ErrorCode::FailedPrecondition) also = no deploy yet, or a file in /proc or /sys (use [`Client::run_service_command`](crate::Client::run_service_command)). Untrusted.
    pub async fn read_path(&self, request: types::ReadPathRequest) -> Result<types::ReadPathResponse, Error> {
        let response = self.wire.read_path(request.into()).await.map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Upload or download a file.
    ///
    /// A one-request URL moving one file straight between you and the machine: run `command` in a shell, or send `http_method` to `url` yourself, before `expire_time`.
    ///
    /// - `upload_archive`: the project's directory, packed, for [`Client::create_project`](crate::Client::create_project)\'s or [`Client::deploy_project`](crate::Client::deploy_project)\'s `upload_id` once the PUT answered 200 with what it holds.
    /// - `upload_file`: one file into a container or volume, replacing any file there (replaces), creating parents.
    /// - download: a file, or a directory as a .tar (`file_name`).
    ///
    /// Plain-text answers: 404 used or expired URL, 409 [`ErrorCode::FailedPrecondition`](crate::ErrorCode::FailedPrecondition), 413 archive over 2 GiB, 422 unsafe archive (a reason per line), 507 disk full. A failed transfer keeps nothing: get a new URL. Errors as [`Client::read_path`](crate::Client::read_path); [`ErrorCode::FailedPrecondition`](crate::ErrorCode::FailedPrecondition) also = downloading neither a file nor a directory, uploading onto a non-file, a mount point, /proc or /sys, or the hostname not yet certified. [`ErrorCode::ResourceExhausted`](crate::ErrorCode::ResourceExhausted) = disk full, or too many transfers.
    pub async fn create_transfer(
        &self,
        request: types::CreateTransferRequest,
    ) -> Result<types::CreateTransferResponse, Error> {
        let response = self
            .wire
            .create_transfer(request.into())
            .await
            .map_err(Error::from_wire)?;
        Ok(response.into_owned().into())
    }

    /// Watch an operation.
    ///
    /// Streams an operation's log from its start, then follows it; the last message is the finished operation. Without a stream: [`Client::get_operation`](crate::Client::get_operation).
    ///
    /// Returns a [`ResponseStream`](crate::ResponseStream) at once: the call is made when the stream is first read, and each response comes as the API sends it. A stream that fails yields its [`Error`](crate::Error) last, once; one that ends by itself just ends. Dropping the stream ends the call.
    ///
    /// ```no_run
    /// # async fn read(pethost: pethost::Client, request: pethost::types::WatchOperationRequest) -> Result<(), pethost::Error> {
    /// let mut responses = pethost.watch_operation(request);
    /// while let Some(response) = responses.next().await {
    ///     println!("{:?}", response?);
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub fn watch_operation(
        &self,
        request: types::WatchOperationRequest,
    ) -> ResponseStream<types::WatchOperationResponse> {
        // The stream owns its client: it outlives this borrow, and goes where it is read.
        let wire = self.wire.clone();
        ResponseStream::new(async move { wire.watch_operation(request.into()).await })
    }

    /// Follow container logs.
    ///
    /// Streams container output after a cursor, then new lines as they are written. [`ErrorCode::OutOfRange`](crate::ErrorCode::OutOfRange) = the machine no longer keeps the cursor's line, so lines after it may be gone too: start over with [`Client::query_container_logs`](crate::Client::query_container_logs) and tail after its `tail_cursor`. Without a stream: [`Client::query_container_logs`](crate::Client::query_container_logs).
    ///
    /// Returns a [`ResponseStream`](crate::ResponseStream) at once: the call is made when the stream is first read, and each response comes as the API sends it. A stream that fails yields its [`Error`](crate::Error) last, once; one that ends by itself just ends. Dropping the stream ends the call.
    ///
    /// ```no_run
    /// # async fn read(pethost: pethost::Client, request: pethost::types::TailContainerLogsRequest) -> Result<(), pethost::Error> {
    /// let mut responses = pethost.tail_container_logs(request);
    /// while let Some(response) = responses.next().await {
    ///     println!("{:?}", response?);
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub fn tail_container_logs(
        &self,
        request: types::TailContainerLogsRequest,
    ) -> ResponseStream<types::TailContainerLogsResponse> {
        // The stream owns its client: it outlives this borrow, and goes where it is read.
        let wire = self.wire.clone();
        ResponseStream::new(async move { wire.tail_container_logs(request.into()).await })
    }

    /// Follow HTTP traffic.
    ///
    /// Streams HTTP requests after a sequence number, then new ones as they finish. Without a stream: [`Client::query_http_traffic`](crate::Client::query_http_traffic).
    ///
    /// Returns a [`ResponseStream`](crate::ResponseStream) at once: the call is made when the stream is first read, and each response comes as the API sends it. A stream that fails yields its [`Error`](crate::Error) last, once; one that ends by itself just ends. Dropping the stream ends the call.
    ///
    /// ```no_run
    /// # async fn read(pethost: pethost::Client, request: pethost::types::TailHttpTrafficRequest) -> Result<(), pethost::Error> {
    /// let mut responses = pethost.tail_http_traffic(request);
    /// while let Some(response) = responses.next().await {
    ///     println!("{:?}", response?);
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub fn tail_http_traffic(
        &self,
        request: types::TailHttpTrafficRequest,
    ) -> ResponseStream<types::TailHttpTrafficResponse> {
        // The stream owns its client: it outlives this borrow, and goes where it is read.
        let wire = self.wire.clone();
        ResponseStream::new(async move { wire.tail_http_traffic(request.into()).await })
    }
}

impl fmt::Debug for Client {
    /// Without the token.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Client")
            .field("base_url", &self.wire.config().base_uri().to_string())
            .finish_non_exhaustive()
    }
}

/// HTTPS that trusts the Mozilla root certificates compiled in: the same on every machine.
fn tls() -> rustls::ClientConfig {
    let roots = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
    // The provider is named. Left to rustls, it is the process's default one, and a build where
    // another crate turns on a second provider has none: making a client would panic.
    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());

    rustls::ClientConfig::builder_with_provider(provider)
        .with_safe_default_protocol_versions()
        .expect("aws-lc-rs has cipher suites for rustls's default TLS versions")
        .with_root_certificates(roots)
        .with_no_client_auth()
}