opendal/services/ghac/
backend.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::env;
19use std::sync::Arc;
20
21use http::Response;
22use http::StatusCode;
23use log::debug;
24use sha2::Digest;
25
26use super::core::*;
27use super::error::parse_error;
28use super::writer::GhacWriter;
29use crate::raw::*;
30use crate::services::ghac::core::GhacCore;
31use crate::services::GhacConfig;
32use crate::*;
33
34fn value_or_env(
35    explicit_value: Option<String>,
36    env_var_name: &str,
37    operation: &'static str,
38) -> Result<String> {
39    if let Some(value) = explicit_value {
40        return Ok(value);
41    }
42
43    env::var(env_var_name).map_err(|err| {
44        let text = format!(
45            "{} not found, maybe not in github action environment?",
46            env_var_name
47        );
48        Error::new(ErrorKind::ConfigInvalid, text)
49            .with_operation(operation)
50            .set_source(err)
51    })
52}
53
54impl Configurator for GhacConfig {
55    type Builder = GhacBuilder;
56
57    #[allow(deprecated)]
58    fn into_builder(self) -> Self::Builder {
59        GhacBuilder {
60            config: self,
61            http_client: None,
62        }
63    }
64}
65
66/// GitHub Action Cache Services support.
67#[doc = include_str!("docs.md")]
68#[derive(Debug, Default)]
69pub struct GhacBuilder {
70    config: GhacConfig,
71
72    #[deprecated(since = "0.53.0", note = "Use `Operator::update_http_client` instead")]
73    http_client: Option<HttpClient>,
74}
75
76impl GhacBuilder {
77    /// set the working directory root of backend
78    pub fn root(mut self, root: &str) -> Self {
79        self.config.root = if root.is_empty() {
80            None
81        } else {
82            Some(root.to_string())
83        };
84
85        self
86    }
87
88    /// set the version that used by cache.
89    ///
90    /// The version is the unique value that provides namespacing.
91    /// It's better to make sure this value is only used by this backend.
92    ///
93    /// If not set, we will use `opendal` as default.
94    pub fn version(mut self, version: &str) -> Self {
95        if !version.is_empty() {
96            self.config.version = Some(version.to_string())
97        }
98
99        self
100    }
101
102    /// Set the endpoint for ghac service.
103    ///
104    /// For example, this is provided as the `ACTIONS_CACHE_URL` environment variable by the GHA runner.
105    ///
106    /// Default: the value of the `ACTIONS_CACHE_URL` environment variable.
107    pub fn endpoint(mut self, endpoint: &str) -> Self {
108        if !endpoint.is_empty() {
109            self.config.endpoint = Some(endpoint.to_string())
110        }
111        self
112    }
113
114    /// Set the runtime token for ghac service.
115    ///
116    /// For example, this is provided as the `ACTIONS_RUNTIME_TOKEN` environment variable by the GHA
117    /// runner.
118    ///
119    /// Default: the value of the `ACTIONS_RUNTIME_TOKEN` environment variable.
120    pub fn runtime_token(mut self, runtime_token: &str) -> Self {
121        if !runtime_token.is_empty() {
122            self.config.runtime_token = Some(runtime_token.to_string())
123        }
124        self
125    }
126
127    /// Specify the http client that used by this service.
128    ///
129    /// # Notes
130    ///
131    /// This API is part of OpenDAL's Raw API. `HttpClient` could be changed
132    /// during minor updates.
133    #[deprecated(since = "0.53.0", note = "Use `Operator::update_http_client` instead")]
134    #[allow(deprecated)]
135    pub fn http_client(mut self, client: HttpClient) -> Self {
136        self.http_client = Some(client);
137        self
138    }
139}
140
141impl Builder for GhacBuilder {
142    const SCHEME: Scheme = Scheme::Ghac;
143    type Config = GhacConfig;
144
145    fn build(self) -> Result<impl Access> {
146        debug!("backend build started: {:?}", self);
147
148        let root = normalize_root(&self.config.root.unwrap_or_default());
149        debug!("backend use root {}", root);
150
151        let service_version = get_cache_service_version();
152        debug!("backend use service version {:?}", service_version);
153
154        let mut version = self
155            .config
156            .version
157            .clone()
158            .unwrap_or_else(|| "opendal".to_string());
159        debug!("backend use version {version}");
160        // ghac requires to use hex digest of Sha256 as version.
161        if matches!(service_version, GhacVersion::V2) {
162            let hash = sha2::Sha256::digest(&version);
163            version = format!("{:x}", hash);
164        }
165
166        let cache_url = self
167            .config
168            .endpoint
169            .unwrap_or_else(|| get_cache_service_url(service_version));
170        if cache_url.is_empty() {
171            return Err(Error::new(
172                ErrorKind::ConfigInvalid,
173                "cache url for ghac not found, maybe not in github action environment?".to_string(),
174            ));
175        }
176
177        let core = GhacCore {
178            info: {
179                let am = AccessorInfo::default();
180                am.set_scheme(Scheme::Ghac)
181                    .set_root(&root)
182                    .set_name(&version)
183                    .set_native_capability(Capability {
184                        stat: true,
185                        stat_has_cache_control: true,
186                        stat_has_content_length: true,
187                        stat_has_content_type: true,
188                        stat_has_content_encoding: true,
189                        stat_has_content_range: true,
190                        stat_has_etag: true,
191                        stat_has_content_md5: true,
192                        stat_has_last_modified: true,
193                        stat_has_content_disposition: true,
194
195                        read: true,
196
197                        write: true,
198                        write_can_multi: true,
199
200                        shared: true,
201
202                        ..Default::default()
203                    });
204
205                // allow deprecated api here for compatibility
206                #[allow(deprecated)]
207                if let Some(client) = self.http_client {
208                    am.update_http_client(|_| client);
209                }
210
211                am.into()
212            },
213            root,
214
215            cache_url,
216            catch_token: value_or_env(
217                self.config.runtime_token,
218                ACTIONS_RUNTIME_TOKEN,
219                "Builder::build",
220            )?,
221            version,
222
223            service_version,
224        };
225
226        Ok(GhacBackend {
227            core: Arc::new(core),
228        })
229    }
230}
231
232/// Backend for github action cache services.
233#[derive(Debug, Clone)]
234pub struct GhacBackend {
235    core: Arc<GhacCore>,
236}
237
238impl Access for GhacBackend {
239    type Reader = HttpBody;
240    type Writer = GhacWriter;
241    type Lister = ();
242    type Deleter = ();
243
244    fn info(&self) -> Arc<AccessorInfo> {
245        self.core.info.clone()
246    }
247
248    /// Some self-hosted GHES instances are backed by AWS S3 services which only returns
249    /// signed url with `GET` method. So we will use `GET` with empty range to simulate
250    /// `HEAD` instead.
251    ///
252    /// In this way, we can support both self-hosted GHES and `github.com`.
253    async fn stat(&self, path: &str, _: OpStat) -> Result<RpStat> {
254        let resp = self.core.ghac_stat(path).await?;
255
256        let status = resp.status();
257        match status {
258            StatusCode::OK | StatusCode::PARTIAL_CONTENT | StatusCode::RANGE_NOT_SATISFIABLE => {
259                let mut meta = parse_into_metadata(path, resp.headers())?;
260                // Correct content length via returning content range.
261                meta.set_content_length(
262                    meta.content_range()
263                        .expect("content range must be valid")
264                        .size()
265                        .expect("content range must contains size"),
266                );
267
268                Ok(RpStat::new(meta))
269            }
270            _ => Err(parse_error(resp)),
271        }
272    }
273
274    async fn read(&self, path: &str, args: OpRead) -> Result<(RpRead, Self::Reader)> {
275        let resp = self.core.ghac_read(path, args.range()).await?;
276
277        let status = resp.status();
278        match status {
279            StatusCode::OK | StatusCode::PARTIAL_CONTENT => {
280                Ok((RpRead::default(), resp.into_body()))
281            }
282            _ => {
283                let (part, mut body) = resp.into_parts();
284                let buf = body.to_buffer().await?;
285                Err(parse_error(Response::from_parts(part, buf)))
286            }
287        }
288    }
289
290    async fn write(&self, path: &str, _: OpWrite) -> Result<(RpWrite, Self::Writer)> {
291        let url = self.core.ghac_get_upload_url(path).await?;
292
293        Ok((
294            RpWrite::default(),
295            GhacWriter::new(self.core.clone(), path.to_string(), url)?,
296        ))
297    }
298}