Skip to main content

opendal_service_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::StatusCode;
23use log::debug;
24use sha2::Digest;
25
26use super::GHAC_SCHEME;
27use super::config::GhacConfig;
28use super::core::GhacCore;
29use super::core::parse_error;
30use super::core::*;
31use super::reader::*;
32use super::writer::GhacLazyWriter;
33use opendal_core::raw::*;
34use opendal_core::*;
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
60impl Debug for GhacBuilder {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("GhacBuilder")
63            .field("config", &self.config)
64            .finish_non_exhaustive()
65    }
66}
67
68impl GhacBuilder {
69    /// set the working directory root of backend
70    pub fn root(mut self, root: &str) -> Self {
71        self.config.root = if root.is_empty() {
72            None
73        } else {
74            Some(root.to_string())
75        };
76
77        self
78    }
79
80    /// set the version that used by cache.
81    ///
82    /// The version is the unique value that provides namespacing.
83    /// It's better to make sure this value is only used by this backend.
84    ///
85    /// If not set, we will use `opendal` as default.
86    pub fn version(mut self, version: &str) -> Self {
87        if !version.is_empty() {
88            self.config.version = Some(version.to_string())
89        }
90
91        self
92    }
93
94    /// Set the endpoint for ghac service.
95    ///
96    /// For example, this is provided as the `ACTIONS_CACHE_URL` environment variable by the GHA runner.
97    ///
98    /// Default: the value of the `ACTIONS_CACHE_URL` environment variable.
99    pub fn endpoint(mut self, endpoint: &str) -> Self {
100        if !endpoint.is_empty() {
101            self.config.endpoint = Some(endpoint.to_string())
102        }
103        self
104    }
105
106    /// Set the runtime token for ghac service.
107    ///
108    /// For example, this is provided as the `ACTIONS_RUNTIME_TOKEN` environment variable by the GHA
109    /// runner.
110    ///
111    /// Default: the value of the `ACTIONS_RUNTIME_TOKEN` environment variable.
112    pub fn runtime_token(mut self, runtime_token: &str) -> Self {
113        if !runtime_token.is_empty() {
114            self.config.runtime_token = Some(runtime_token.to_string())
115        }
116        self
117    }
118}
119
120impl Builder for GhacBuilder {
121    type Config = GhacConfig;
122
123    fn build(self) -> Result<impl Service> {
124        debug!("backend build started: {self:?}");
125
126        let root = normalize_root(&self.config.root.unwrap_or_default());
127        debug!("backend use root {root}");
128
129        let service_version = get_cache_service_version();
130        debug!("backend use service version {service_version:?}");
131
132        let mut version = self
133            .config
134            .version
135            .clone()
136            .unwrap_or_else(|| "opendal".to_string());
137        debug!("backend use version {version}");
138        // ghac requires to use hex digest of Sha256 as version.
139        if matches!(service_version, GhacVersion::V2) {
140            let hash = sha2::Sha256::digest(&version);
141            version = format_digest_hex(hash);
142        }
143
144        let cache_url = self
145            .config
146            .endpoint
147            .unwrap_or_else(|| get_cache_service_url(service_version));
148        if cache_url.is_empty() {
149            return Err(Error::new(
150                ErrorKind::ConfigInvalid,
151                "cache url for ghac not found, maybe not in github action environment?".to_string(),
152            ));
153        }
154
155        let core = GhacCore {
156            info: ServiceInfo::new(GHAC_SCHEME, &root, &version),
157            capability: Capability {
158                stat: true,
159
160                read: true,
161
162                write: true,
163                write_can_multi: true,
164
165                shared: true,
166
167                ..Default::default()
168            },
169            root,
170
171            cache_url,
172            catch_token: value_or_env(
173                self.config.runtime_token,
174                ACTIONS_RUNTIME_TOKEN,
175                "Builder::build",
176            )?,
177            version,
178
179            service_version,
180        };
181
182        Ok(GhacBackend {
183            core: Arc::new(core),
184        })
185    }
186}
187
188fn format_digest_hex(digest: impl AsRef<[u8]>) -> String {
189    use std::fmt::Write;
190
191    let digest = digest.as_ref();
192    let mut output = String::with_capacity(digest.len() * 2);
193    for byte in digest {
194        write!(&mut output, "{byte:02x}").expect("writing to String must succeed");
195    }
196    output
197}
198
199/// Backend for github action cache services.
200#[derive(Debug, Clone)]
201pub struct GhacBackend {
202    pub(crate) core: Arc<GhacCore>,
203}
204
205impl Service for GhacBackend {
206    type Reader = oio::StreamReader<GhacReader>;
207    type Writer = GhacLazyWriter;
208    type Lister = ();
209    type Deleter = ();
210    type Copier = ();
211
212    fn info(&self) -> ServiceInfo {
213        self.core.info.clone()
214    }
215
216    fn capability(&self) -> Capability {
217        self.core.capability
218    }
219
220    async fn create_dir(
221        &self,
222        _ctx: &OperationContext,
223        _path: &str,
224        _args: OpCreateDir,
225    ) -> Result<RpCreateDir> {
226        Err(Error::new(
227            ErrorKind::Unsupported,
228            "operation is not supported",
229        ))
230    }
231
232    /// Some self-hosted GHES instances are backed by AWS S3 services which only returns
233    /// signed url with `GET` method. So we will use `GET` with empty range to simulate
234    /// `HEAD` instead.
235    ///
236    /// In this way, we can support both self-hosted GHES and `github.com`.
237    async fn stat(&self, ctx: &OperationContext, path: &str, _: OpStat) -> Result<RpStat> {
238        let resp = self.core.ghac_stat(ctx, path).await?;
239
240        let status = resp.status();
241        match status {
242            StatusCode::OK | StatusCode::PARTIAL_CONTENT | StatusCode::RANGE_NOT_SATISFIABLE => {
243                let meta = parse_into_metadata(path, resp.headers())?;
244                Ok(RpStat::new(meta))
245            }
246            _ => Err(parse_error(resp)),
247        }
248    }
249    fn read(&self, ctx: &OperationContext, path: &str, args: OpRead) -> Result<Self::Reader> {
250        let output: oio::StreamReader<GhacReader> = {
251            Ok(oio::StreamReader::new(GhacReader::new(
252                self.clone(),
253                ctx.clone(),
254                path,
255                args,
256            )))
257        }?;
258
259        Ok(output)
260    }
261
262    fn write(&self, ctx: &OperationContext, path: &str, _: OpWrite) -> Result<Self::Writer> {
263        Ok(GhacLazyWriter::new(
264            self.core.clone(),
265            ctx.clone(),
266            ctx.executor().clone(),
267            path.to_string(),
268        ))
269    }
270
271    fn delete(&self, _ctx: &OperationContext) -> Result<Self::Deleter> {
272        Err(Error::new(
273            ErrorKind::Unsupported,
274            "operation is not supported",
275        ))
276    }
277
278    fn list(&self, _ctx: &OperationContext, _path: &str, _args: OpList) -> Result<Self::Lister> {
279        Err(Error::new(
280            ErrorKind::Unsupported,
281            "operation is not supported",
282        ))
283    }
284
285    fn copy(
286        &self,
287        _ctx: &OperationContext,
288        _from: &str,
289        _to: &str,
290        _args: OpCopy,
291        _opts: OpCopier,
292    ) -> Result<Self::Copier> {
293        Err(Error::new(
294            ErrorKind::Unsupported,
295            "operation is not supported",
296        ))
297    }
298
299    async fn rename(
300        &self,
301        _ctx: &OperationContext,
302        _from: &str,
303        _to: &str,
304        _args: OpRename,
305    ) -> Result<RpRename> {
306        Err(Error::new(
307            ErrorKind::Unsupported,
308            "operation is not supported",
309        ))
310    }
311
312    async fn presign(
313        &self,
314        _ctx: &OperationContext,
315        _path: &str,
316        _args: OpPresign,
317    ) -> Result<RpPresign> {
318        Err(Error::new(
319            ErrorKind::Unsupported,
320            "operation is not supported",
321        ))
322    }
323}