pethost 0.2.9

Client for the API of Pethost, managed hosting for Docker Compose projects: deploy, logs, HTTP traffic, files
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
// @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 and a database's password ([`Project::database`](crate::types::Project::database)`.connection`) 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).
    ///
    /// A database (Postgres or Valkey): `database`, no files, and a `project_id` without \"\_\", not a number (only digits, or 0x and hex digits) and no name of every container's /etc/hosts (localhost, ip6-localhost, ip6-loopback, \.\.\.): it is the host name apps use. The machine writes its compose.yaml and /.env and deploys it at its default size, with no address; then [`Client::link_database`](crate::Client::link_database) the apps that use it.
    ///
    /// 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; + [`MachineUpdatePending`](crate::types::MachineUpdatePending) = `database`, on a machine whose software predates databases. 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. `restore_snapshot` takes the project's own snapshots only. A database's snapshot as a new database is the person's to make, in the panel (the database's Backups tab, Restore as a new database): no tool makes one.
    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())
    }

    /// Link projects to a database.
    ///
    /// Lets projects reach a database project: inside each linked project the database is the host `<database_project_id>` at its port, and [`Project::database`](crate::types::Project::database) says how to sign in (its password with [`Client::get_project`](crate::Client::get_project) `include_secret_values`). Takes effect at once and deploys nothing. [`ErrorCode::InvalidArgument`](crate::ErrorCode::InvalidArgument) = a project in both link and unlink. [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) = no such project. [`ErrorCode::FailedPrecondition`](crate::ErrorCode::FailedPrecondition) = `database_project_id` is no database; + [`DatabaseLinkRefusal`](crate::types::DatabaseLinkRefusal) = the app is a database, has a service named or aliased like the database, or there are 20 links already; + [`MachineUpdatePending`](crate::types::MachineUpdatePending) = the machine's software predates databases. Answers with the database's Project.
    pub async fn link_database(
        &self,
        request: types::LinkDatabaseRequest,
    ) -> Result<types::LinkDatabaseResponse, Error> {
        let response = self
            .wire
            .link_database(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`).
    /// - `download_database`: a database project's dump, made as it downloads: Postgres's for `pg_restore` (roles are not in it), Valkey's .rdb. One at a time per database.
    ///
    /// 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), 503 [`ErrorCode::Unavailable`](crate::ErrorCode::Unavailable), 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, the hostname not yet certified, or a dump of a project that is no database or is not running. [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) also = a Postgres database the server lacks (the message lists its databases). [`ErrorCode::ResourceExhausted`](crate::ErrorCode::ResourceExhausted) = disk full, or too many transfers. [`ErrorCode::Unavailable`](crate::ErrorCode::Unavailable) = Postgres runs but does not answer yet: try again shortly.
    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()
}