greentic_deployer/bundle_upload/
error.rs1use 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}