Skip to main content

mz_testdrive/action/
glue.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use anyhow::{Context, anyhow, bail};
11use aws_sdk_glue::types::{Compatibility, DataFormat, RegistryId, SchemaId};
12
13use crate::action::{ControlFlow, State};
14use crate::parser::BuiltinCommand;
15
16/// Register a schema in AWS Glue Schema Registry and stash its version UUID in a
17/// testdrive variable.
18///
19/// This keeps a single source of truth in the `.td` file: define the schema
20/// once as a pretty-printed `$ set` variable, then reference it both here (to
21/// register it) and in `kafka-ingest` (to encode records) — so the body is
22/// never written twice.
23///
24/// ```text
25/// $ set my-schema={
26///     "type": "record", "name": "row",
27///     "fields": [{"name": "a", "type": "long"}]
28///   }
29///
30/// $ glue-create-schema registry=my-registry name=my-schema set-version-id-var=my-version-id schema=${my-schema}
31/// ```
32///
33/// Arguments:
34///   * `name` (required): the schema name.
35///   * `schema` (required unless given as the command body): the schema
36///     definition. Typically a `${...}` reference to a `$ set` variable.
37///   * `set-version-id-var` (optional): testdrive variable to receive the
38///     returned `SchemaVersionId`. Omit when the schema is referenced only by
39///     name (e.g. a negative test that registers a schema just to be rejected).
40///   * `registry` (optional): registry name. Omit to target Glue's implicit
41///     default registry.
42///   * `data-format` (optional, default `avro`): one of `avro`, `json`,
43///     `protobuf`.
44///   * `compatibility` (optional, default `backward`).
45///
46/// If a schema with this name already exists, a new version is registered
47/// instead — which is how schema-evolution tests register v2 atop v1.
48pub async fn run_create_schema(
49    mut cmd: BuiltinCommand,
50    state: &mut State,
51) -> Result<ControlFlow, anyhow::Error> {
52    let name = cmd.args.string("name")?;
53    let version_id_var = cmd.args.opt_string("set-version-id-var");
54    let registry = cmd.args.opt_string("registry");
55    let schema_arg = cmd.args.opt_string("schema");
56    let data_format = match cmd
57        .args
58        .opt_string("data-format")
59        .unwrap_or_else(|| "avro".into())
60        .to_lowercase()
61        .as_str()
62    {
63        "avro" => DataFormat::Avro,
64        "json" => DataFormat::Json,
65        "protobuf" => DataFormat::Protobuf,
66        other => bail!("unknown data-format: {}", other),
67    };
68    let compatibility = parse_compatibility(
69        &cmd.args
70            .opt_string("compatibility")
71            .unwrap_or_else(|| "backward".into()),
72    )?;
73    cmd.args.done()?;
74
75    // The schema definition comes from the `schema=` argument (typically a
76    // `${...}` reference to a `$ set` variable) or, failing that, the command
77    // body.
78    let definition = match schema_arg {
79        Some(schema) => schema,
80        None => cmd.input.join("\n"),
81    };
82    if definition.trim().is_empty() {
83        bail!("glue-create-schema requires a `schema=` argument or a schema definition body");
84    }
85
86    println!(
87        "Registering Glue schema {:?} (registry {:?})...",
88        name,
89        registry.as_deref().unwrap_or("<default>"),
90    );
91
92    let glue = aws_sdk_glue::Client::new(&state.aws_config);
93
94    let mut create = glue
95        .create_schema()
96        .schema_name(&name)
97        .data_format(data_format)
98        .compatibility(compatibility)
99        .schema_definition(&definition);
100    if let Some(registry) = &registry {
101        create = create.registry_id(RegistryId::builder().registry_name(registry).build());
102    }
103
104    let version_id = match create.send().await {
105        Ok(resp) => resp.schema_version_id().map(|s| s.to_string()),
106        Err(err) => {
107            // The schema already exists — register a new version of it. This is
108            // the schema-evolution path (v2 atop v1).
109            let svc = err.into_service_error();
110            if svc.is_already_exists_exception() {
111                let mut schema_id = SchemaId::builder().schema_name(&name);
112                if let Some(registry) = &registry {
113                    schema_id = schema_id.registry_name(registry);
114                }
115                let resp = glue
116                    .register_schema_version()
117                    .schema_id(schema_id.build())
118                    .schema_definition(&definition)
119                    .send()
120                    .await
121                    .context("registering new Glue schema version")?;
122                resp.schema_version_id().map(|s| s.to_string())
123            } else {
124                return Err(anyhow::Error::new(svc).context("creating Glue schema"));
125            }
126        }
127    };
128    if let Some(var) = version_id_var {
129        let version_id =
130            version_id.ok_or_else(|| anyhow!("Glue did not return a schema version id"))?;
131        state.cmd_vars.insert(var, version_id);
132    }
133    Ok(ControlFlow::Continue)
134}
135
136/// Verify that a schema in AWS Glue Schema Registry has the expected
137/// compatibility policy, looked up by name.
138///
139/// A sink applies a compatibility policy only when it first creates a schema,
140/// and never overwrites it afterward. Record decoding cannot observe that
141/// policy, so this reads it back to assert the create path applied the intended
142/// value.
143///
144/// Arguments:
145///   * `name` (required): the schema name.
146///   * `compatibility` (required): the expected compatibility, e.g. `backward`
147///     or `full`. Matched case-insensitively.
148///   * `registry` (optional): registry name. Omit to target Glue's implicit
149///     default registry.
150pub async fn run_verify_compatibility(
151    mut cmd: BuiltinCommand,
152    state: &State,
153) -> Result<ControlFlow, anyhow::Error> {
154    let name = cmd.args.string("name")?;
155    let registry = cmd.args.opt_string("registry");
156    let expected = parse_compatibility(&cmd.args.string("compatibility")?)?;
157    cmd.args.done()?;
158
159    let glue = aws_sdk_glue::Client::new(&state.aws_config);
160    let mut schema_id = SchemaId::builder().schema_name(&name);
161    if let Some(registry) = &registry {
162        schema_id = schema_id.registry_name(registry);
163    }
164    let resp = glue
165        .get_schema()
166        .schema_id(schema_id.build())
167        .send()
168        .await
169        .context("fetching Glue schema")?;
170
171    let actual = resp
172        .compatibility()
173        .ok_or_else(|| anyhow!("Glue schema {name:?} has no compatibility set"))?;
174    if *actual != expected {
175        bail!("Glue schema {name:?} has compatibility {actual:?}, expected {expected:?}");
176    }
177    Ok(ControlFlow::Continue)
178}
179
180/// Parse a Glue compatibility policy from its testdrive spelling, matched
181/// case-insensitively.
182fn parse_compatibility(s: &str) -> Result<Compatibility, anyhow::Error> {
183    Ok(match s.to_lowercase().as_str() {
184        "backward" => Compatibility::Backward,
185        "backward_all" => Compatibility::BackwardAll,
186        "forward" => Compatibility::Forward,
187        "forward_all" => Compatibility::ForwardAll,
188        "full" => Compatibility::Full,
189        "full_all" => Compatibility::FullAll,
190        "none" => Compatibility::None,
191        "disabled" => Compatibility::Disabled,
192        other => bail!("unknown compatibility: {}", other),
193    })
194}