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 super::DEFAULT_SCHEME;
30use crate::raw::*;
31use crate::services::ghac::core::GhacCore;
32use crate::services::GhacConfig;
33use crate::*;
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!("{env_var_name} not found, maybe not in github action environment?");
45        Error::new(ErrorKind::ConfigInvalid, text)
46            .with_operation(operation)
47            .set_source(err)
48    })
49}
50
51impl Configurator for GhacConfig {
52    type Builder = GhacBuilder;
53
54    #[allow(deprecated)]
55    fn into_builder(self) -> Self::Builder {
56        GhacBuilder {
57            config: self,
58            http_client: None,
59        }
60    }
61}
62
63/// GitHub Action Cache Services support.
64#[doc = include_str!("docs.md")]
65#[derive(Debug, Default)]
66pub struct GhacBuilder {
67    config: GhacConfig,
68
69    #[deprecated(since = "0.53.0", note = "Use `Operator::update_http_client` instead")]
70    http_client: Option<HttpClient>,
71}
72
73impl GhacBuilder {
74    /// set the working directory root of backend
75    pub fn root(mut self, root: &str) -> Self {
76        self.config.root = if root.is_empty() {
77            None
78        } else {
79            Some(root.to_string())
80        };
81
82        self
83    }
84
85    /// set the version that used by cache.
86    ///
87    /// The version is the unique value that provides namespacing.
88    /// It's better to make sure this value is only used by this backend.
89    ///
90    /// If not set, we will use `opendal` as default.
91    pub fn version(mut self, version: &str) -> Self {
92        if !version.is_empty() {
93            self.config.version = Some(version.to_string())
94        }
95
96        self
97    }
98
99    /// Set the endpoint for ghac service.
100    ///
101    /// For example, this is provided as the `ACTIONS_CACHE_URL` environment variable by the GHA runner.
102    ///
103    /// Default: the value of the `ACTIONS_CACHE_URL` environment variable.
104    pub fn endpoint(mut self, endpoint: &str) -> Self {
105        if !endpoint.is_empty() {
106            self.config.endpoint = Some(endpoint.to_string())
107        }
108        self
109    }
110
111    /// Set the runtime token for ghac service.
112    ///
113    /// For example, this is provided as the `ACTIONS_RUNTIME_TOKEN` environment variable by the GHA
114    /// runner.
115    ///
116    /// Default: the value of the `ACTIONS_RUNTIME_TOKEN` environment variable.
117    pub fn runtime_token(mut self, runtime_token: &str) -> Self {
118        if !runtime_token.is_empty() {
119            self.config.runtime_token = Some(runtime_token.to_string())
120        }
121        self
122    }
123
124    /// Specify the http client that used by this service.
125    ///
126    /// # Notes
127    ///
128    /// This API is part of OpenDAL's Raw API. `HttpClient` could be changed
129    /// during minor updates.
130    #[deprecated(since = "0.53.0", note = "Use `Operator::update_http_client` instead")]
131    #[allow(deprecated)]
132    pub fn http_client(mut self, client: HttpClient) -> Self {
133        self.http_client = Some(client);
134        self
135    }
136}
137
138impl Builder for GhacBuilder {
139    type Config = GhacConfig;
140
141    fn build(self) -> Result<impl Access> {
142        debug!("backend build started: {self:?}");
143
144        let root = normalize_root(&self.config.root.unwrap_or_default());
145        debug!("backend use root {root}");
146
147        let service_version = get_cache_service_version();
148        debug!("backend use service version {service_version:?}");
149
150        let mut version = self
151            .config
152            .version
153            .clone()
154            .unwrap_or_else(|| "opendal".to_string());
155        debug!("backend use version {version}");
156        // ghac requires to use hex digest of Sha256 as version.
157        if matches!(service_version, GhacVersion::V2) {
158            let hash = sha2::Sha256::digest(&version);
159            version = format!("{hash:x}");
160        }
161
162        let cache_url = self
163            .config
164            .endpoint
165            .unwrap_or_else(|| get_cache_service_url(service_version));
166        if cache_url.is_empty() {
167            return Err(Error::new(
168                ErrorKind::ConfigInvalid,
169                "cache url for ghac not found, maybe not in github action environment?".to_string(),
170            ));
171        }
172
173        let core = GhacCore {
174            info: {
175                let am = AccessorInfo::default();
176                am.set_scheme(DEFAULT_SCHEME)
177                    .set_root(&root)
178                    .set_name(&version)
179                    .set_native_capability(Capability {
180                        stat: true,
181
182                        read: true,
183
184                        write: true,
185                        write_can_multi: true,
186
187                        shared: true,
188
189                        ..Default::default()
190                    });
191
192                // allow deprecated api here for compatibility
193                #[allow(deprecated)]
194                if let Some(client) = self.http_client {
195                    am.update_http_client(|_| client);
196                }
197
198                am.into()
199            },
200            root,
201
202            cache_url,
203            catch_token: value_or_env(
204                self.config.runtime_token,
205                ACTIONS_RUNTIME_TOKEN,
206                "Builder::build",
207            )?,
208            version,
209
210            service_version,
211        };
212
213        Ok(GhacBackend {
214            core: Arc::new(core),
215        })
216    }
217}
218
219/// Backend for github action cache services.
220#[derive(Debug, Clone)]
221pub struct GhacBackend {
222    core: Arc<GhacCore>,
223}
224
225impl Access for GhacBackend {
226    type Reader = HttpBody;
227    type Writer = GhacWriter;
228    type Lister = ();
229    type Deleter = ();
230
231    fn info(&self) -> Arc<AccessorInfo> {
232        self.core.info.clone()
233    }
234
235    /// Some self-hosted GHES instances are backed by AWS S3 services which only returns
236    /// signed url with `GET` method. So we will use `GET` with empty range to simulate
237    /// `HEAD` instead.
238    ///
239    /// In this way, we can support both self-hosted GHES and `github.com`.
240    async fn stat(&self, path: &str, _: OpStat) -> Result<RpStat> {
241        let resp = self.core.ghac_stat(path).await?;
242
243        let status = resp.status();
244        match status {
245            StatusCode::OK | StatusCode::PARTIAL_CONTENT | StatusCode::RANGE_NOT_SATISFIABLE => {
246                let mut meta = parse_into_metadata(path, resp.headers())?;
247                // Correct content length via returning content range.
248                meta.set_content_length(
249                    meta.content_range()
250                        .expect("content range must be valid")
251                        .size()
252                        .expect("content range must contains size"),
253                );
254
255                Ok(RpStat::new(meta))
256            }
257            _ => Err(parse_error(resp)),
258        }
259    }
260
261    async fn read(&self, path: &str, args: OpRead) -> Result<(RpRead, Self::Reader)> {
262        let resp = self.core.ghac_read(path, args.range()).await?;
263
264        let status = resp.status();
265        match status {
266            StatusCode::OK | StatusCode::PARTIAL_CONTENT => {
267                Ok((RpRead::default(), resp.into_body()))
268            }
269            _ => {
270                let (part, mut body) = resp.into_parts();
271                let buf = body.to_buffer().await?;
272                Err(parse_error(Response::from_parts(part, buf)))
273            }
274        }
275    }
276
277    async fn write(&self, path: &str, _: OpWrite) -> Result<(RpWrite, Self::Writer)> {
278        let url = self.core.ghac_get_upload_url(path).await?;
279
280        Ok((
281            RpWrite::default(),
282            GhacWriter::new(self.core.clone(), path.to_string(), url)?,
283        ))
284    }
285}