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!("{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    const SCHEME: Scheme = Scheme::Ghac;
140    type Config = GhacConfig;
141
142    fn build(self) -> Result<impl Access> {
143        debug!("backend build started: {self:?}");
144
145        let root = normalize_root(&self.config.root.unwrap_or_default());
146        debug!("backend use root {root}");
147
148        let service_version = get_cache_service_version();
149        debug!("backend use service version {service_version:?}");
150
151        let mut version = self
152            .config
153            .version
154            .clone()
155            .unwrap_or_else(|| "opendal".to_string());
156        debug!("backend use version {version}");
157        // ghac requires to use hex digest of Sha256 as version.
158        if matches!(service_version, GhacVersion::V2) {
159            let hash = sha2::Sha256::digest(&version);
160            version = format!("{hash:x}");
161        }
162
163        let cache_url = self
164            .config
165            .endpoint
166            .unwrap_or_else(|| get_cache_service_url(service_version));
167        if cache_url.is_empty() {
168            return Err(Error::new(
169                ErrorKind::ConfigInvalid,
170                "cache url for ghac not found, maybe not in github action environment?".to_string(),
171            ));
172        }
173
174        let core = GhacCore {
175            info: {
176                let am = AccessorInfo::default();
177                am.set_scheme(Scheme::Ghac)
178                    .set_root(&root)
179                    .set_name(&version)
180                    .set_native_capability(Capability {
181                        stat: true,
182
183                        read: true,
184
185                        write: true,
186                        write_can_multi: true,
187
188                        shared: true,
189
190                        ..Default::default()
191                    });
192
193                // allow deprecated api here for compatibility
194                #[allow(deprecated)]
195                if let Some(client) = self.http_client {
196                    am.update_http_client(|_| client);
197                }
198
199                am.into()
200            },
201            root,
202
203            cache_url,
204            catch_token: value_or_env(
205                self.config.runtime_token,
206                ACTIONS_RUNTIME_TOKEN,
207                "Builder::build",
208            )?,
209            version,
210
211            service_version,
212        };
213
214        Ok(GhacBackend {
215            core: Arc::new(core),
216        })
217    }
218}
219
220/// Backend for github action cache services.
221#[derive(Debug, Clone)]
222pub struct GhacBackend {
223    core: Arc<GhacCore>,
224}
225
226impl Access for GhacBackend {
227    type Reader = HttpBody;
228    type Writer = GhacWriter;
229    type Lister = ();
230    type Deleter = ();
231
232    fn info(&self) -> Arc<AccessorInfo> {
233        self.core.info.clone()
234    }
235
236    /// Some self-hosted GHES instances are backed by AWS S3 services which only returns
237    /// signed url with `GET` method. So we will use `GET` with empty range to simulate
238    /// `HEAD` instead.
239    ///
240    /// In this way, we can support both self-hosted GHES and `github.com`.
241    async fn stat(&self, path: &str, _: OpStat) -> Result<RpStat> {
242        let resp = self.core.ghac_stat(path).await?;
243
244        let status = resp.status();
245        match status {
246            StatusCode::OK | StatusCode::PARTIAL_CONTENT | StatusCode::RANGE_NOT_SATISFIABLE => {
247                let mut meta = parse_into_metadata(path, resp.headers())?;
248                // Correct content length via returning content range.
249                meta.set_content_length(
250                    meta.content_range()
251                        .expect("content range must be valid")
252                        .size()
253                        .expect("content range must contains size"),
254                );
255
256                Ok(RpStat::new(meta))
257            }
258            _ => Err(parse_error(resp)),
259        }
260    }
261
262    async fn read(&self, path: &str, args: OpRead) -> Result<(RpRead, Self::Reader)> {
263        let resp = self.core.ghac_read(path, args.range()).await?;
264
265        let status = resp.status();
266        match status {
267            StatusCode::OK | StatusCode::PARTIAL_CONTENT => {
268                Ok((RpRead::default(), resp.into_body()))
269            }
270            _ => {
271                let (part, mut body) = resp.into_parts();
272                let buf = body.to_buffer().await?;
273                Err(parse_error(Response::from_parts(part, buf)))
274            }
275        }
276    }
277
278    async fn write(&self, path: &str, _: OpWrite) -> Result<(RpWrite, Self::Writer)> {
279        let url = self.core.ghac_get_upload_url(path).await?;
280
281        Ok((
282            RpWrite::default(),
283            GhacWriter::new(self.core.clone(), path.to_string(), url)?,
284        ))
285    }
286}