Skip to main content

greentic_deployer/bundle_upload/
error.rs

1use thiserror::Error;
2
3#[derive(Debug)]
4pub struct AwsCredentialsRefreshHelp {
5    pub configure_command: &'static str,
6    pub session_token_check_command: &'static str,
7    pub session_token_unset_command: &'static str,
8    pub sso_login_command: &'static str,
9    pub profile_env_command: &'static str,
10    pub profile_configure_command: &'static str,
11    pub profile_sso_login_command: &'static str,
12    pub verify_command: &'static str,
13}
14
15impl std::fmt::Display for AwsCredentialsRefreshHelp {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        write!(
18            f,
19            "If you use access keys, configure or refresh them:\n  {}\n\nIf access keys are configured but AWS still reports an expired token, check for a stale session token:\n  {}\n  {}\n\nIf you use AWS SSO, reauthenticate:\n  {}\n\nIf you use a named profile:\n  {}\n  {}\n  {}\n\nVerify the same credentials with:\n  {}",
20            self.configure_command,
21            self.session_token_check_command,
22            self.session_token_unset_command,
23            self.sso_login_command,
24            self.profile_env_command,
25            self.profile_configure_command,
26            self.profile_sso_login_command,
27            self.verify_command
28        )
29    }
30}
31
32#[cfg(feature = "bundle-upload-aws")]
33pub static AWS_CREDENTIALS_REFRESH_HELP: AwsCredentialsRefreshHelp = AwsCredentialsRefreshHelp {
34    configure_command: "aws configure",
35    session_token_check_command: "aws configure get aws_session_token",
36    session_token_unset_command: "unset AWS_SESSION_TOKEN AWS_SECURITY_TOKEN",
37    sso_login_command: "aws sso login",
38    profile_env_command: "export AWS_PROFILE=<profile>",
39    profile_configure_command: "aws configure --profile <profile>",
40    profile_sso_login_command: "aws sso login --profile <profile>",
41    verify_command: "aws sts get-caller-identity",
42};
43
44#[derive(Debug, Error)]
45pub enum BundleUploadError {
46    #[error(
47        "unsupported upload scheme '{0}'; expected one of: s3://, gs://, https://*.blob.core.windows.net/"
48    )]
49    InvalidUrl(String),
50
51    #[error("scheme '{scheme}' requires building greentic-deployer with --features {feature}")]
52    FeatureNotEnabled { scheme: String, feature: String },
53
54    #[error(
55        "bucket '{0}' is taken in the global S3 namespace; pick another name (S3 bucket names are globally unique)"
56    )]
57    BucketAlreadyExistsInOtherAccount(String),
58
59    #[error("access denied for {action} on {resource}: required IAM permissions: {required_perms}")]
60    AccessDenied {
61        action: String,
62        resource: String,
63        required_perms: String,
64    },
65
66    #[error("object '{0}' not found; run upload-bundle again to recreate")]
67    ObjectMissing(String),
68
69    #[error("greentic-start warmup failed (exit {exit_code}):\n{stderr}")]
70    WarmupFailed { exit_code: i32, stderr: String },
71
72    #[error("network error after retries: {0}")]
73    NetworkTransient(String),
74
75    #[error(
76        "AWS credentials could not be resolved; configure with `aws configure` or set AWS_PROFILE / AWS_ACCESS_KEY_ID env vars"
77    )]
78    CredentialsUnresolved,
79
80    #[error("AWS credentials need to be refreshed while {action}.\n\n{help}")]
81    AwsCredentialsRefreshRequired {
82        action: String,
83        help: &'static AwsCredentialsRefreshHelp,
84    },
85
86    #[error("digest mismatch: expected {expected}, computed {actual}")]
87    DigestMismatch { expected: String, actual: String },
88
89    #[error("io error: {0}")]
90    Io(#[from] std::io::Error),
91
92    #[error("{0}")]
93    Other(String),
94}
95
96impl BundleUploadError {
97    pub fn message_key(&self) -> &'static str {
98        match self {
99            Self::InvalidUrl(_) => "bundle_upload.invalid_url",
100            Self::FeatureNotEnabled { .. } => "bundle_upload.feature_not_enabled",
101            Self::BucketAlreadyExistsInOtherAccount(_) => {
102                "bundle_upload.s3.bucket_already_exists_in_other_account"
103            }
104            Self::AccessDenied { .. } => "bundle_upload.access_denied",
105            Self::ObjectMissing(_) => "bundle_upload.object_missing",
106            Self::WarmupFailed { .. } => "bundle_upload.warmup_failed",
107            Self::NetworkTransient(_) => "bundle_upload.network_transient",
108            Self::CredentialsUnresolved => "bundle_upload.aws.credentials_unresolved",
109            Self::AwsCredentialsRefreshRequired { .. } => {
110                "bundle_upload.aws.credentials_refresh_required"
111            }
112            Self::DigestMismatch { .. } => "bundle_upload.digest_mismatch",
113            Self::Io(_) => "bundle_upload.io",
114            Self::Other(_) => "bundle_upload.other",
115        }
116    }
117}
118
119pub type BundleUploadResult<T> = std::result::Result<T, BundleUploadError>;
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn message_keys_cover_all_error_variants() {
127        let io_error = std::io::Error::other("disk full");
128        let cases = vec![
129            (
130                BundleUploadError::InvalidUrl("ftp://bundle".into()),
131                "bundle_upload.invalid_url",
132            ),
133            (
134                BundleUploadError::FeatureNotEnabled {
135                    scheme: "gs".into(),
136                    feature: "bundle-upload-gcp".into(),
137                },
138                "bundle_upload.feature_not_enabled",
139            ),
140            (
141                BundleUploadError::BucketAlreadyExistsInOtherAccount("taken".into()),
142                "bundle_upload.s3.bucket_already_exists_in_other_account",
143            ),
144            (
145                BundleUploadError::AccessDenied {
146                    action: "PutObject".into(),
147                    resource: "s3://bucket/key".into(),
148                    required_perms: "s3:PutObject".into(),
149                },
150                "bundle_upload.access_denied",
151            ),
152            (
153                BundleUploadError::ObjectMissing("s3://bucket/key".into()),
154                "bundle_upload.object_missing",
155            ),
156            (
157                BundleUploadError::WarmupFailed {
158                    exit_code: 42,
159                    stderr: "boom".into(),
160                },
161                "bundle_upload.warmup_failed",
162            ),
163            (
164                BundleUploadError::NetworkTransient("timeout".into()),
165                "bundle_upload.network_transient",
166            ),
167            (
168                BundleUploadError::CredentialsUnresolved,
169                "bundle_upload.aws.credentials_unresolved",
170            ),
171            (
172                BundleUploadError::AwsCredentialsRefreshRequired {
173                    action: "uploading bundle".into(),
174                    help: &TEST_AWS_HELP,
175                },
176                "bundle_upload.aws.credentials_refresh_required",
177            ),
178            (
179                BundleUploadError::DigestMismatch {
180                    expected: "sha256:expected".into(),
181                    actual: "sha256:actual".into(),
182                },
183                "bundle_upload.digest_mismatch",
184            ),
185            (BundleUploadError::Io(io_error), "bundle_upload.io"),
186            (
187                BundleUploadError::Other("misc".into()),
188                "bundle_upload.other",
189            ),
190        ];
191
192        for (err, key) in cases {
193            assert_eq!(err.message_key(), key);
194            assert!(!err.to_string().is_empty());
195        }
196    }
197
198    #[test]
199    fn aws_credentials_refresh_help_renders_all_commands() {
200        let rendered = TEST_AWS_HELP.to_string();
201        for expected in [
202            "aws configure",
203            "aws configure get aws_session_token",
204            "unset AWS_SESSION_TOKEN",
205            "aws sso login",
206            "export AWS_PROFILE",
207            "aws sts get-caller-identity",
208        ] {
209            assert!(
210                rendered.contains(expected),
211                "missing {expected}: {rendered}"
212            );
213        }
214    }
215
216    static TEST_AWS_HELP: AwsCredentialsRefreshHelp = AwsCredentialsRefreshHelp {
217        configure_command: "aws configure",
218        session_token_check_command: "aws configure get aws_session_token",
219        session_token_unset_command: "unset AWS_SESSION_TOKEN AWS_SECURITY_TOKEN",
220        sso_login_command: "aws sso login",
221        profile_env_command: "export AWS_PROFILE=<profile>",
222        profile_configure_command: "aws configure --profile <profile>",
223        profile_sso_login_command: "aws sso login --profile <profile>",
224        verify_command: "aws sts get-caller-identity",
225    };
226}