Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/commands/migration/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,13 @@ mod tests {
// Resolve it, as a real render would while streaming to psql.
secrets.resolve("application_password").await.unwrap();

// Value at the deepest level. Only reached because redact_secrets
// formats with `{:?}`, which walks `source()`; `{}` would leak it.
let simulated_psql_error = anyhow!(
"psql exited with code 1: ERROR: duplicate key value\nDETAIL: Key (password)=(hunter2) already exists."
);
)
.context("could not render include: error in \"roles/app_role.sql\"")
.context("Migration '20260101000000-bootstrap' failed");

let redacted = redact_secrets(simulated_psql_error, &secrets, SqlDialect::Postgres);

Expand Down
20 changes: 20 additions & 0 deletions src/docs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//! URLs of published doc pages, for anywhere Spawn produces output — errors,
//! `--help`, warnings.
//!
//! Centralised so a moved page is one edit here, not a hunt through every
//! string naming it. `tests/doc_links.rs` verifies each still resolves.

/// Declares a link and registers it in `ALL`, so a new constant can't escape
/// the link test.
macro_rules! docs {
($($(#[$m:meta])* $name:ident = $slug:literal;)*) => {
$($(#[$m])* pub const $name: &str = concat!("https://docs.spawn.dev/", $slug, "/");)*
/// Every link above, as (constant name, slug), for `tests/doc_links.rs`.
pub const ALL: &[(&str, &str)] = &[$((stringify!($name), $slug)),*];
};
}

docs! {
/// Defining `[secrets]` and the sources available to them.
SECRETS = "guides/secrets";
}
6 changes: 4 additions & 2 deletions src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@ pub enum MigrationError {
info: ExistingMigrationInfo,
},

/// Database or connection error
#[error("database error: {0}")]
/// Database or connection error.
///
/// Transparent: a prefix would duplicate the line `source()` already gives.
#[error(transparent)]
Database(#[from] anyhow::Error),

// Could not get advisory lock
Expand Down
68 changes: 35 additions & 33 deletions src/engine/postgres_psql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -901,30 +901,33 @@ impl PSQL {

let duration = start_time.elapsed().as_secs_f32();

// Determine status based on session 1 result
let (status, migration_error) = match &migration_result {
Ok(()) => (MigrationStatus::Success, None),
Err(EngineError::ExecutionFailed { exit_code, stderr }) => {
if stderr.contains("Could not acquire advisory lock") {
return Err(MigrationError::AdvisoryLock(std::io::Error::new(
std::io::ErrorKind::Other,
stderr.clone(),
)));
// Determine status based on session 1 result.
let (status, migration_error): (MigrationStatus, Option<anyhow::Error>) =
match migration_result {
Ok(()) => (MigrationStatus::Success, None),
Err(EngineError::ExecutionFailed { exit_code, stderr }) => {
if stderr.contains("Could not acquire advisory lock") {
return Err(MigrationError::AdvisoryLock(std::io::Error::other(stderr)));
}
(
MigrationStatus::Failure,
Some(anyhow!("psql exited with code {}: {}", exit_code, stderr)),
)
}
(
MigrationStatus::Failure,
Some(format!("psql exited with code {}: {}", exit_code, stderr)),
)
}
Err(EngineError::Io(e)) => {
// Writer failed (e.g. an unresolved secret), not psql itself.
// Still record a Failure row — SQL may already have run.
(
MigrationStatus::Failure,
Some(format!("failed while streaming migration SQL: {}", e)),
)
}
};
Err(EngineError::Io(e)) => {
// Writer failed (e.g. an unresolved secret), not psql itself.
// Still record a Failure row — SQL may already have run.
(
MigrationStatus::Failure,
Some(
anyhow::Error::from(e).context("failed while streaming migration SQL"),
),
)
}
};

// NotRecorded holds strings; `{:#}` flattens the chain onto one line.
let migration_error_text = migration_error.as_ref().map(|e| format!("{:#}", e));

// Session 2: Record the outcome (success or failure)
let record_result = self
Expand All @@ -948,25 +951,24 @@ impl PSQL {
name: migration_name.to_string(),
migration_outcome: MigrationStatus::Success,
migration_error: None,
recording_error: format!("{}", record_err),
recording_error: format!("{:#}", anyhow::Error::new(record_err)),
});
}
// Both migration and recording failed
return Err(MigrationError::NotRecorded {
name: migration_name.to_string(),
migration_outcome: MigrationStatus::Failure,
migration_error: migration_error.clone(),
recording_error: format!("{}", record_err),
migration_error: migration_error_text,
recording_error: format!("{:#}", anyhow::Error::new(record_err)),
});
}

// If the migration itself failed (but was recorded), return that error
if let Some(err_msg) = migration_error {
return Err(MigrationError::Database(anyhow!(
"Migration '{}' failed: {}",
migration_name,
err_msg
)));
// If the migration itself failed (but was recorded), return that error.
// A context layer, not an interpolated string, so the chain survives.
if let Some(err) = migration_error {
return Err(MigrationError::Database(
err.context(format!("Migration '{}' failed", migration_name)),
));
}

Ok("Migration applied successfully".to_string())
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod cli;
pub mod commands;
pub mod completions;
pub mod config;
pub mod docs;
pub mod engine;
pub mod escape;
pub mod hash;
Expand Down
75 changes: 72 additions & 3 deletions src/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,31 @@ impl SecretsRepository {
)
}

/// The error for a `secret()` call naming something that isn't configured.
///
/// Lists the defined names so a typo is obvious; names are safe to print,
/// their values are not. Single line, because minijinja appends
/// `(in <file>:<line>)` after it.
fn undefined_secret(&self, name: &str) -> anyhow::Error {
let mut known: Vec<&str> = self.secrets.keys().map(String::as_str).collect();
known.sort_unstable();
let defined = if known.is_empty() {
"none are defined".to_string()
} else {
format!("defined: {}", known.join(", "))
};

anyhow!(
"secret '{name}' is not defined in spawn.toml ({defined}); see {url}",
url = crate::docs::SECRETS
)
}

pub async fn resolve(&self, name: &str) -> Result<String> {
let secret = self
.secrets
.get(name)
.ok_or_else(|| anyhow!("no secret named '{}' is defined in spawn.toml", name))?;
.ok_or_else(|| self.undefined_secret(name))?;

// Always resolve for real, even when masking the result: this is
// what lets `build`/`test build` verify a secret is reachable
Expand Down Expand Up @@ -383,10 +403,59 @@ mod tests {
}

#[tokio::test]
async fn missing_secret_definition_errors() {
async fn missing_secret_with_none_configured_says_so_and_shows_the_fix() {
let repository = repo(HashMap::new(), "prod", SecretsRenderMode::Revealed);
let err = repository.resolve("nope").await.unwrap_err();
assert!(err.to_string().contains("no secret named 'nope'"));
let message = err.to_string();
assert!(message.contains("secret 'nope' is not defined in spawn.toml"));
assert!(
message.contains("none are defined"),
"should say nothing is configured, got: {message}"
);
assert!(
message.contains("https://docs.spawn.dev/guides/secrets/"),
"should point at the secrets guide, got: {message}"
);
}

#[tokio::test]
async fn missing_secret_lists_the_defined_names_but_never_their_values() {
let definitions = defs(vec![
(
"zeta_password",
SecretDefinition {
default: SecretSource::Literal {
value: "s3cret-zeta-value".to_string(),
insecure: true,
},
environments: HashMap::new(),
},
),
(
"alpha_password",
SecretDefinition {
default: SecretSource::Literal {
value: "s3cret-alpha-value".to_string(),
insecure: true,
},
environments: HashMap::new(),
},
),
]);
let repository = repo(definitions, "prod", SecretsRenderMode::Revealed);
let err = repository.resolve("aplha_password").await.unwrap_err();
let message = format!("{:?}", err);

assert!(message.contains("secret 'aplha_password' is not defined in spawn.toml"));
assert!(
message.contains("defined: alpha_password, zeta_password"),
"should list defined names in sorted order, got: {message}"
);
// Names are safe to print; their values are not.
assert!(
!message.contains("s3cret-alpha-value") && !message.contains("s3cret-zeta-value"),
"a configured secret's value must never appear, got: {message}"
);
}

#[tokio::test]
Expand Down
85 changes: 83 additions & 2 deletions src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,9 @@ pub struct Generation {
pub struct StreamingGeneration {
store: Store,
template_contents: String,
/// The template's real path, used as its minijinja template name so render
/// errors name the file on disk.
template_label: String,
/// Name of the migration or test being rendered, exposed to templates
/// as `builtin.script_name` (see `BuiltinContext`).
script_name: String,
Expand Down Expand Up @@ -404,8 +407,8 @@ impl StreamingGeneration {
self.engine.clone(),
);
let mut env = template_env(self.store, &self.engine, self.secrets, builtin)?;
env.add_template("migration.sql", &self.template_contents)?;
let tmpl = env.get_template("migration.sql")?;
env.add_template(&self.template_label, &self.template_contents)?;
let tmpl = env.get_template(&self.template_label)?;
tmpl.render_to_write(
context!(env => self.environment, variables => self.variables),
writer,
Expand Down Expand Up @@ -507,6 +510,7 @@ pub async fn generate_streaming_with_store(
Ok(StreamingGeneration {
store,
template_contents: contents,
template_label: path.to_string(),
script_name: script_name.to_string(),
script_type,
environment: environment.to_string(),
Expand Down Expand Up @@ -964,6 +968,7 @@ mod tests {
StreamingGeneration {
store,
template_contents: template_contents.to_string(),
template_label: "migrations/test/up.sql".to_string(),
script_name: "test".to_string(),
script_type: ScriptType::Migration,
environment: "prod".to_string(),
Expand Down Expand Up @@ -1053,4 +1058,80 @@ mod tests {
"different up.sql content must produce different checksums"
);
}

/// minijinja hides an `{% include %}` failure's real cause behind a
/// `BadInclude` wrapper, reachable only via `.source()`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn include_failure_keeps_the_real_cause_reachable() {
use crate::config::FolderPather;
use opendal::services::Memory;
use opendal::Operator;

let op = Operator::new(Memory::default()).unwrap();
op.write(
"components/roles/app_role.sql",
r#"CREATE ROLE app_user WITH LOGIN PASSWORD {{ secret("application_password") }};"#,
)
.await
.unwrap();

let pather = FolderPather {
spawn_folder: "".to_string(),
};
let store = Store::new(Box::new(Latest::new("").unwrap()), op.clone(), pather).unwrap();

let gen = StreamingGeneration {
store,
template_contents: "BEGIN;\n\n{% include \"roles/app_role.sql\" %}\n\nCOMMIT;\n"
.to_string(),
template_label: "migrations/20260101000000-bootstrap/up.sql".to_string(),
script_name: "20260101000000-bootstrap".to_string(),
script_type: ScriptType::Migration,
environment: "staging".to_string(),
variables: crate::variables::Variables::default(),
engine: EngineType::PostgresPSQL,
secrets: Arc::new(SecretsRepository::empty(op)),
pin_hash: None,
};

let mut buffer = Vec::new();
let err = gen.render_to_writer(&mut buffer).unwrap_err();
let chain = format!("{:?}", err);

assert!(
chain.contains("is not defined in spawn.toml"),
"the underlying cause must survive the BadInclude wrapper, got: {chain}"
);
assert!(
chain.contains("roles/app_role.sql:1"),
"the cause must name the component and the line within it, got: {chain}"
);
assert!(
chain.contains("migrations/20260101000000-bootstrap/up.sql:3"),
"the outer frame must name the migration's real path and line, got: {chain}"
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn render_errors_are_labelled_with_the_templates_real_path() {
let gen = streaming_generation(
r#"SELECT {{ secret("nope") }};"#,
SecretsRepository::empty(
opendal::Operator::new(opendal::services::Memory::default()).unwrap(),
),
);

let mut buffer = Vec::new();
let err = gen.render_to_writer(&mut buffer).unwrap_err();
let chain = format!("{:?}", err);

assert!(
chain.contains("migrations/test/up.sql"),
"expected the real template path in the error, got: {chain}"
);
assert!(
!chain.contains("migration.sql"),
"the hardcoded placeholder name must be gone, got: {chain}"
);
}
}
17 changes: 17 additions & 0 deletions tests/doc_links.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//! Starlight derives a page's URL from its path under
//! `docs/src/content/docs`, so checking the file checks the URL.

use std::path::Path;

#[test]
fn every_doc_link_points_at_a_real_page() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/src/content/docs");

for (name, slug) in spawn_db::docs::ALL {
let page = root.join(slug);
assert!(
["md", "mdx"].iter().any(|ext| page.with_extension(ext).exists()),
"docs::{name} points at /{slug}/, but no docs/src/content/docs/{slug}.md or .mdx exists"
);
}
}
Loading
Loading