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