1use crate::cli::commands::test::TestValidationError;
16use crate::client::{
17 ConflictRecord, ConnectionError, DatabaseValidationError, ProductionClusterRecord,
18};
19use crate::config::ConfigError;
20use crate::project::analysis::deployment_snapshot::DeploymentSnapshotError;
21use crate::project::compiler::typecheck::TypeCheckError;
22use crate::project::error::{DependencyError, ProjectError};
23use crate::project::ir::object_id::ObjectId;
24use crate::secret_resolver::SecretResolveError;
25use crate::types::TypesError;
26use chrono::{DateTime, Local};
27use owo_colors::{OwoColorize, Stream, Style};
28use thiserror::Error;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum MissingObject {
37 Cluster(String),
39 Database(String),
41 SchemaObject {
43 schema: String,
44 name: String,
45 kind: String,
46 },
47 Role(String),
49}
50
51#[derive(Debug, Error)]
56pub enum CliError {
57 #[error(transparent)]
59 Project(#[from] ProjectError),
60
61 #[error(transparent)]
63 Config(#[from] ConfigError),
64
65 #[error(transparent)]
67 Connection(#[from] ConnectionError),
68
69 #[error(transparent)]
71 DeploymentSnapshot(#[from] DeploymentSnapshotError),
72
73 #[error(transparent)]
75 Dependency(#[from] DependencyError),
76
77 #[error(transparent)]
79 Validation(DatabaseValidationError),
80
81 #[error(transparent)]
83 Types(#[from] TypesError),
84
85 #[error("deployment conflict: {count} schema{plural} updated since deployment started",
87 count = conflicts.len(),
88 plural = if conflicts.len() == 1 { "" } else { "s" })]
89 DeploymentConflict { conflicts: Vec<ConflictRecord> },
90
91 #[error("failed to determine git SHA for staging environment name")]
93 GitShaFailed,
94
95 #[error("git repository has uncommitted changes")]
97 GitDirty,
98
99 #[error("no schemas found to deploy")]
101 NoSchemas,
102
103 #[error("refusing to overwrite production objects")]
105 ProductionOverwriteNotAllowed {
106 objects: Vec<(String, String, String)>, },
108
109 #[error("refusing to deploy dev overlay onto production cluster")]
111 DevTargetsProductionCluster { cluster: ProductionClusterRecord },
112
113 #[error("mz-deploy infrastructure is not fully initialized")]
116 SetupRequired { missing: Vec<MissingObject> },
117
118 #[error("`_mz_deploy` is owned by role '{owner}', not by the current role '{current_role}'")]
121 SetupNotDatabaseOwner { owner: String, current_role: String },
122
123 #[error("`mz-deploy setup` requires a superuser role (current role: '{current_role}')")]
126 SetupRequiresSuperuser { current_role: String },
127
128 #[error("unit test '{test_name}' on '{object_id}' cannot be desugared: {reason}")]
132 InvalidUnitTestTarget {
133 test_name: String,
134 object_id: String,
135 reason: String,
136 },
137
138 #[error("failed to create deployment tracking table: {source}")]
140 DeploymentTableCreationFailed { source: ConnectionError },
141
142 #[error("failed to execute SQL statement: {source}")]
144 SqlExecutionFailed {
145 statement: String,
146 source: ConnectionError,
147 },
148
149 #[error("failed to repoint sink {sink}: {reason}")]
151 SinkRepointFailed { sink: String, reason: String },
152
153 #[error("failed to write deployment state to tracking table: {source}")]
155 DeploymentStateWriteFailed { source: ConnectionError },
156
157 #[error("invalid staging environment name: '{name}'")]
159 InvalidEnvironmentName { name: String },
160
161 #[error("schema '{schema}' does not exist in database '{database}'")]
163 SchemaNotFound { database: String, schema: String },
164
165 #[error("cluster '{name}' does not exist")]
167 ClusterNotFound { name: String },
168
169 #[error("{failed} test{plural} failed, {passed} passed",
171 plural = if *failed == 1 { "" } else { "s" })]
172 TestsFailed { failed: usize, passed: usize },
173
174 #[error("no tests matched filter '{filter}'")]
176 TestsFilterMissed { filter: String },
177
178 #[error(transparent)]
180 TestValidationFailed(#[from] TestValidationError),
181
182 #[error(transparent)]
184 TypeCheckFailed(#[from] TypeCheckError),
185
186 #[error("timeout waiting for deployment '{name}' to be ready after {seconds} seconds")]
188 ReadyTimeout { name: String, seconds: u64 },
189
190 #[error("I/O error: {0}")]
192 Io(#[from] std::io::Error),
193
194 #[error("some clusters are still hydrating")]
196 ClustersHydrating,
197
198 #[error("deployment '{name}' is failing due to cluster health issues")]
200 DeploymentFailing { name: String },
201
202 #[error("cannot add new objects to existing stable schema '{database}.{schema}'")]
204 NewObjectInExistingStableSchema {
205 database: String,
206 schema: String,
207 objects: Vec<String>,
208 },
209
210 #[error("failed to resolve secret '{secret_name}': {source}")]
212 SecretResolution {
213 secret_name: String,
214 source: SecretResolveError,
215 },
216
217 #[error("cluster '{cluster}' is not ready: {reason}")]
219 ClusterNotReady { cluster: String, reason: String },
220
221 #[error("current role is not a member of any mz-deploy role")]
223 NoMzDeployRole,
224
225 #[error("current role is a member of multiple mz-deploy roles: {}", roles.join(", "))]
227 MultipleMzDeployRoles { roles: Vec<String> },
228
229 #[error(
231 "this command requires the '{required_role}' role, but you are connected as '{current_role}'"
232 )]
233 RoleNotAuthorized {
234 current_role: String,
235 required_role: String,
236 },
237
238 #[error(
240 "user {role} lacks CREATEDB privilege, required to create overlay \
241 database {overlay_db}.\n\n\
242 Ask an administrator to run:\n GRANT CREATEDB ON SYSTEM TO {role};"
243 )]
244 MissingCreatedb { role: String, overlay_db: String },
245
246 #[error("cannot derive project name from directory '{path}'")]
249 InvalidProjectDirectory { path: String },
250
251 #[error("undeclared external dependencies")]
253 UndeclaredDependencies { undeclared: Vec<ObjectId> },
254
255 #[error("declared dependencies not found in target database")]
257 DeclaredDependenciesMissing { missing: Vec<ObjectId> },
258
259 #[error("{0}")]
261 Message(String),
262}
263
264impl CliError {
265 pub fn hint(&self) -> Option<String> {
269 match self {
270 Self::Project(_) => None,
271 Self::Connection(e) => match e {
272 ConnectionError::DeploymentNotFound { deploy_id } => Some(format!(
273 "verify the staging environment name '{}' is correct, or deploy to staging first using:\n \
274 {} {} {} --name {}",
275 deploy_id.if_supports_color(Stream::Stderr, |t| t.yellow()),
276 "mz-deploy".if_supports_color(Stream::Stderr, |t| t.cyan()),
277 "stage".if_supports_color(Stream::Stderr, |t| t.cyan()),
278 ".".if_supports_color(Stream::Stderr, |t| t.cyan()),
279 deploy_id.if_supports_color(Stream::Stderr, |t| t.cyan())
280 )),
281 ConnectionError::DeploymentAlreadyPromoted { .. } => Some(
282 "this staging environment has already been applied to production.\n\
283 Deploy a new staging environment to make changes"
284 .to_string(),
285 ),
286 _ => None,
287 },
288 Self::DeploymentConflict { conflicts } => {
289 let conflict_list = conflicts
290 .iter()
291 .map(|c| {
292 let promoted_datetime: DateTime<Local> = c.promoted_at.into();
293 let promoted_str = promoted_datetime
294 .format("%a %b %d %H:%M:%S %Y %z")
295 .to_string();
296 format!(" - {}.{} (last promoted by '{}' at {})",
297 c.database.if_supports_color(Stream::Stderr, |t| t.yellow()),
298 c.schema.if_supports_color(Stream::Stderr, |t| t.yellow()),
299 c.deploy_id,
300 promoted_str)
301 })
302 .collect::<Vec<_>>()
303 .join("\n");
304 let force_style = Style::new().yellow().bold();
305 Some(format!(
306 "the following schemas were updated in production after your deployment started:\n{}\n\n\
307 Rebase your deployment by running:\n \
308 {} {} {}\n \
309 {} {} {} --name <staging-env>\n\n\
310 Or use {} to force the deployment (may overwrite recent changes)",
311 conflict_list,
312 "mz-deploy".if_supports_color(Stream::Stderr, |t| t.cyan()),
313 "abort".if_supports_color(Stream::Stderr, |t| t.cyan()),
314 "--name <staging-env>".if_supports_color(Stream::Stderr, |t| t.cyan()),
315 "mz-deploy".if_supports_color(Stream::Stderr, |t| t.cyan()),
316 "stage".if_supports_color(Stream::Stderr, |t| t.cyan()),
317 ".".if_supports_color(Stream::Stderr, |t| t.cyan()),
318 "--force".if_supports_color(Stream::Stderr, |t| force_style.style(t))
319 ))
320 }
321 Self::GitShaFailed => Some(
322 "either run mz-deploy from inside a git repository, or provide a staging environment name using:\n \
323 mz-deploy stage . --name <environment-name>"
324 .to_string(),
325 ),
326 Self::GitDirty => Some(
327 "commit or stash your changes before deploying, or use the --allow-dirty flag to deploy anyway"
328 .to_string(),
329 ),
330 Self::NoSchemas => Some(
331 "create at least one schema directory under your project directory (e.g., materialize/public/)"
332 .to_string(),
333 ),
334 Self::ProductionOverwriteNotAllowed { objects } => {
335 let object_list = objects
336 .iter()
337 .take(5)
338 .map(|(db, schema, obj)| {
339 format!(
340 " - {}.{}.{}",
341 db.if_supports_color(Stream::Stderr, |t| t.yellow()),
342 schema.if_supports_color(Stream::Stderr, |t| t.yellow()),
343 obj.if_supports_color(Stream::Stderr, |t| t.yellow())
344 )
345 })
346 .collect::<Vec<_>>()
347 .join("\n");
348 let more = if objects.len() > 5 {
349 format!("\n ... and {} more", objects.len() - 5)
350 } else {
351 String::new()
352 };
353 Some(format!(
354 "the following objects already exist in production:\n{}{}\n\n\
355 To update existing objects, use blue/green deployment:\n \
356 {} {} {}\n \
357 {} {} {} <staging-env>",
358 object_list,
359 more,
360 "mz-deploy".if_supports_color(Stream::Stderr, |t| t.cyan()),
361 "stage".if_supports_color(Stream::Stderr, |t| t.cyan()),
362 ".".if_supports_color(Stream::Stderr, |t| t.cyan()),
363 "mz-deploy".if_supports_color(Stream::Stderr, |t| t.cyan()),
364 "apply".if_supports_color(Stream::Stderr, |t| t.cyan()),
365 "--staging-env".if_supports_color(Stream::Stderr, |t| t.cyan())
366 ))
367 }
368 Self::SetupRequired { missing } => {
369 let list = missing
370 .iter()
371 .take(5)
372 .map(|m| match m {
373 MissingObject::Cluster(name) => {
374 format!(" • cluster {}", name.if_supports_color(Stream::Stderr, |t| t.yellow()))
375 }
376 MissingObject::Database(name) => {
377 format!(" • database {}", name.if_supports_color(Stream::Stderr, |t| t.yellow()))
378 }
379 MissingObject::SchemaObject { schema, name, kind } => {
380 format!(
381 " • {} _mz_deploy.{}.{}",
382 kind,
383 schema.if_supports_color(Stream::Stderr, |t| t.yellow()),
384 name.if_supports_color(Stream::Stderr, |t| t.yellow()),
385 )
386 }
387 MissingObject::Role(name) => {
388 format!(" • role {}", name.if_supports_color(Stream::Stderr, |t| t.yellow()))
389 }
390 })
391 .collect::<Vec<_>>()
392 .join("\n");
393 let more = if missing.len() > 5 {
394 format!("\n ... and {} more", missing.len() - 5)
395 } else {
396 String::new()
397 };
398 Some(format!(
399 "the following mz-deploy objects are missing:\n{}{}\n\n\
400 run {} as an admin role with the {}, {}, and {} system \
401 privileges to initialize or self-heal the installation.",
402 list,
403 more,
404 "mz-deploy setup".if_supports_color(Stream::Stderr, |t| t.cyan()),
405 "CREATECLUSTER".if_supports_color(Stream::Stderr, |t| t.cyan()),
406 "CREATEDB".if_supports_color(Stream::Stderr, |t| t.cyan()),
407 "CREATEROLE".if_supports_color(Stream::Stderr, |t| t.cyan()),
408 ))
409 }
410 Self::InvalidUnitTestTarget { .. } => Some(
411 "unit tests can only target CREATE VIEW or CREATE MATERIALIZED VIEW \
412 statements. Move the test to a view/MV or remove it from the target \
413 object's file."
414 .to_string(),
415 ),
416 Self::SetupNotDatabaseOwner {
417 owner,
418 current_role,
419 } => Some(format!(
420 "{} is the only command that writes to `_mz_deploy`, and only \
421 the owning role can do so.\n\n \
422 Re-run as {}, or have {} run {} to transfer ownership.",
423 "mz-deploy setup".if_supports_color(Stream::Stderr, |t| t.cyan()),
424 owner.if_supports_color(Stream::Stderr, |t| t.cyan()),
425 owner.if_supports_color(Stream::Stderr, |t| t.cyan()),
426 format!("ALTER DATABASE _mz_deploy OWNER TO {}", current_role).if_supports_color(Stream::Stderr, |t| t.cyan()),
427 )),
428 Self::SetupRequiresSuperuser { current_role } => Some(format!(
429 "{} grants {} and {} on the system. With RBAC enabled, only \
430 a superuser can issue system grants, and the active role \
431 {} is not a superuser.\n\n \
432 Re-run setup using a Materialize admin user, or have an \
433 admin run it once on your behalf. After setup completes, \
434 ordinary deployer/developer/monitor roles use mz-deploy \
435 normally — only this bootstrap step requires elevated \
436 privileges.",
437 "mz-deploy setup".if_supports_color(Stream::Stderr, |t| t.cyan()),
438 "CREATEDB".if_supports_color(Stream::Stderr, |t| t.cyan()),
439 "CREATECLUSTER".if_supports_color(Stream::Stderr, |t| t.cyan()),
440 current_role.if_supports_color(Stream::Stderr, |t| t.cyan()),
441 )),
442 Self::DevTargetsProductionCluster { cluster } => {
443 let promoted_local: DateTime<Local> = cluster.promoted_at.into();
444 let promoted_str = promoted_local.format("%b %d, %Y").to_string();
445 Some(format!(
446 "cluster {} hosts a promoted deployment ({}.{}, promoted {}) \
447 and cannot be targeted by dev.\n\n\
448 re-run with a non-production cluster, e.g.:\n\n \
449 mz-deploy dev <dev-cluster>",
450 cluster
451 .cluster_name
452 .if_supports_color(Stream::Stderr, |t| t.yellow()),
453 cluster.database,
454 cluster.schema,
455 promoted_str,
456 ))
457 }
458 Self::DeploymentTableCreationFailed { .. } => Some(
459 "ensure your database user has CREATE privileges on the database"
460 .to_string(),
461 ),
462 Self::SqlExecutionFailed { statement, .. } => Some(format!(
463 "SQL statement:\n {}",
464 statement.lines().take(5).collect::<Vec<_>>().join("\n ")
465 )),
466 Self::SinkRepointFailed { sink, .. } => Some(format!(
467 "the sink '{}' could not be repointed to the new upstream object.\n\
468 This may happen if:\n \
469 - The new object has an incompatible schema (e.g., Avro schema mismatch)\n \
470 - The replacement object doesn't exist in the new schema\n\n\
471 To proceed, you may need to manually drop and recreate the sink",
472 sink.if_supports_color(Stream::Stderr, |t| t.yellow())
473 )),
474 Self::DeploymentStateWriteFailed { .. } => Some(
475 "the SQL was applied successfully, but deployment tracking failed.\n\
476 The next deployment may re-apply some objects"
477 .to_string(),
478 ),
479 Self::InvalidEnvironmentName { .. } => Some(
480 "environment names must contain only alphanumeric characters, hyphens, and underscores"
481 .to_string(),
482 ),
483 Self::SchemaNotFound { database, schema } => Some(format!(
484 "create the schema first, or check that you're connected to the correct database.\n \
485 CREATE SCHEMA {}.{}",
486 database.if_supports_color(Stream::Stderr, |t| t.cyan()),
487 schema.if_supports_color(Stream::Stderr, |t| t.cyan())
488 )),
489 Self::ClusterNotFound { name } => Some(format!(
490 "create the cluster first:\n \
491 CREATE CLUSTER {} SIZE = '{}' REPLICATION FACTOR = 1",
492 name.if_supports_color(Stream::Stderr, |t| t.cyan()),
493 "M.1-small".if_supports_color(Stream::Stderr, |t| t.cyan())
494 )),
495 Self::TestsFailed { .. } => None,
496 Self::TestValidationFailed(_) => Some(
497 "review the validation error above and update your test to match the schema.\n\
498 Run 'mz-deploy compile' to regenerate the type cache if needed"
499 .to_string(),
500 ),
501 Self::TypeCheckFailed(_) => None,
502 Self::ReadyTimeout { .. } => Some(
503 "deployment is taking longer than expected to hydrate. You can:\n \
504 - Increase timeout with --timeout flag\n \
505 - Check cluster replica status with: mz-deploy ready <env>"
506 .to_string(),
507 ),
508 Self::ClustersHydrating => Some("check cluster replica status with: mz-deploy ready <env>".to_string()),
509 Self::DeploymentFailing { .. } => Some(
510 "one or more clusters are not ready. Check for:\n \
511 - Missing replicas (cluster has no replicas configured)\n \
512 - OOM-looping replicas (3+ OOM kills in 24 hours)\n\n\
513 Use 'mz-deploy ready <env>' for details"
514 .to_string(),
515 ),
516 Self::SecretResolution { .. } => Some(
517 "check that environment variables are set and function arguments are string literals"
518 .to_string(),
519 ),
520 Self::NewObjectInExistingStableSchema { objects, .. } => {
521 let object_list = objects
522 .iter()
523 .take(5)
524 .map(|obj| format!(" - {}", obj.if_supports_color(Stream::Stderr, |t| t.yellow())))
525 .collect::<Vec<_>>()
526 .join("\n");
527 let more = if objects.len() > 5 {
528 format!("\n ... and {} more", objects.len() - 5)
529 } else {
530 String::new()
531 };
532 Some(format!(
533 "the following new objects cannot be added during incremental deployment:\n{}{}\n\n\
534 To add new objects, either:\n \
535 - Place them in a new schema (new schemas deploy via blue-green swap)\n \
536 - Deploy them separately before modifying existing objects in the same schema",
537 object_list,
538 more,
539 ))
540 }
541 Self::ClusterNotReady { cluster, reason } => {
542 if reason.contains("replication factor") {
543 Some(format!(
544 "the cluster '{}' has no replicas running. Ensure the cluster is scaled up:\n \
545 ALTER CLUSTER {} SET (REPLICATION FACTOR = 1)",
546 cluster.if_supports_color(Stream::Stderr, |t| t.yellow()),
547 cluster.if_supports_color(Stream::Stderr, |t| t.cyan())
548 ))
549 } else {
550 Some(format!(
551 "grant USAGE on the cluster to your role:\n \
552 GRANT USAGE ON CLUSTER {} TO <your-role>",
553 cluster.if_supports_color(Stream::Stderr, |t| t.cyan())
554 ))
555 }
556 }
557 Self::NoMzDeployRole => Some(
558 "your role must be a member of one of: materialize_deployer, materialize_developer, or materialize_monitor.\n\
559 Run 'mz-deploy setup' first, then grant a role:\n \
560 GRANT \"materialize_developer\" TO <your-role>"
561 .to_string(),
562 ),
563 Self::MultipleMzDeployRoles { .. } => Some(
564 "each database role should have exactly one mz-deploy role.\n\
565 Set up distinct profiles with separate roles for deploying vs. monitoring"
566 .to_string(),
567 ),
568 Self::RoleNotAuthorized { current_role, required_role } => {
569 let action = if current_role == "materialize_developer" {
570 "developing"
571 } else {
572 "monitoring"
573 };
574 Some(format!(
575 "your current profile is configured for {}, but this command requires deploying privileges.\n\
576 Switch to a profile whose role is a member of '{}'",
577 action,
578 required_role.if_supports_color(Stream::Stderr, |t| t.cyan())
579 ))
580 }
581 Self::UndeclaredDependencies { undeclared } => {
582 let dep_list = undeclared
583 .iter()
584 .map(|d| format!("\t\"{}\",", d))
585 .collect::<Vec<_>>()
586 .join("\n");
587 Some(format!(
588 "add these to project.toml:\n\n dependencies = [\n{}\n ]\n\n \
589 then run `{}` to fetch their schemas.",
590 dep_list,
591 "mz-deploy lock".if_supports_color(Stream::Stderr, |t| t.cyan())
592 ))
593 }
594 Self::DeclaredDependenciesMissing { missing } => {
595 let list = missing
596 .iter()
597 .map(|d| format!(" {} {}", "×".if_supports_color(Stream::Stderr, |t| t.red()), d))
598 .collect::<Vec<_>>()
599 .join("\n");
600 Some(format!(
601 "{}\n\nthese objects must exist before running `lock`. Check that they \
602 are deployed and that your profile points to the correct environment.",
603 list
604 ))
605 }
606 Self::Config(ConfigError::NoProfileConfigured) => Some(format!(
607 "record a default profile for this project:\n \
608 {}\n\n\
609 or pass {} for a one-off run, or export {}.",
610 "mz-deploy profile set <name>".if_supports_color(Stream::Stderr, |t| t.cyan()),
611 "--profile <name>".if_supports_color(Stream::Stderr, |t| t.cyan()),
612 "MZ_DEPLOY_PROFILE=<name>".if_supports_color(Stream::Stderr, |t| t.cyan()),
613 )),
614 Self::Config(_)
615 | Self::Validation(_)
616 | Self::Types(_)
617 | Self::DeploymentSnapshot(_)
618 | Self::Dependency(_) => {
619 None
621 }
622 Self::MissingCreatedb { .. } => None,
623 Self::InvalidProjectDirectory { .. } => None,
624 Self::Io(_) | Self::Message(_) | Self::TestsFilterMissed { .. } => None,
625 }
626 }
627}
628
629impl From<DatabaseValidationError> for CliError {
630 fn from(error: DatabaseValidationError) -> Self {
631 CliError::Validation(error)
632 }
633}
634
635impl From<String> for CliError {
636 fn from(msg: String) -> Self {
637 CliError::Message(msg)
638 }
639}