From 29cc5efd7b54878ab1affa5ae64678bd50c632ef Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Mon, 31 Aug 2026 14:14:17 -0700 Subject: [PATCH] feat(release): Add gated --endpoint-bdd-json option Lets the release script carry endpoint-bdd-1.json for a service, without that being what decides whether the service uses the BDD endpoint provider. The provider is selected by codegen purely on whether the model file is present, so an unconditional copy would move every service onto the BDD path on the first release that carried the model. copyFileIfAlreadyPresent therefore checks the destination first and updates the file only for a service that already has one. Adding the file by hand stays the act of opting a service in, which is what makes a phased rollout possible; the script keeps it refreshed from then on. The existing copyFile is left alone for the other models. Gating those would be wrong: a service legitimately gains waiters-2.json or endpoint-tests.json for the first time, and those should land on the release that carries them. The argument is optional, so callers that do not pass it are unaffected. Note that release automation lives outside this repo and has to start passing --endpoint-bdd-json for any of this to take effect. Testing: - Seven tests driving UpdateServiceMain.main with temp directories, so the option registration and the resolved destination are covered along with the gate. An unregistered option would silently ignore the argument, which a test of the helper alone would not catch. - Cases: absent destination is not created; existing destination is overwritten; an empty existing file still counts as present; omitting the argument leaves an opted-in model alone; the other five models are still copied unconditionally; and the gate applies to DynamoDB's nested codegen-resources directory rather than the module root. - Mutation checked. Swapping in the unconditional copyFile, and gating on the source instead of the destination, each fail endpointBddJson_notAlreadyPresent_isNotCopied. - release-scripts had no test tree, so junit-jupiter and assertj-core are added at test scope. checkstyle clean, spotbugs 0 bugs. --- release-scripts/pom.xml | 10 + .../awssdk/release/UpdateServiceMain.java | 28 ++- .../awssdk/release/UpdateServiceMainTest.java | 220 ++++++++++++++++++ 3 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 release-scripts/src/test/java/software/amazon/awssdk/release/UpdateServiceMainTest.java diff --git a/release-scripts/pom.xml b/release-scripts/pom.xml index df7eb42ae8d5..577a6894f9b8 100644 --- a/release-scripts/pom.xml +++ b/release-scripts/pom.xml @@ -83,5 +83,15 @@ log4j-slf4j-impl runtime + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + diff --git a/release-scripts/src/main/java/software/amazon/awssdk/release/UpdateServiceMain.java b/release-scripts/src/main/java/software/amazon/awssdk/release/UpdateServiceMain.java index cc55cd880f92..485d14b4c6df 100644 --- a/release-scripts/src/main/java/software/amazon/awssdk/release/UpdateServiceMain.java +++ b/release-scripts/src/main/java/software/amazon/awssdk/release/UpdateServiceMain.java @@ -36,7 +36,8 @@ --service-module-name service-module-name --service-json /path/to/service-2.json [--paginators-json /path/to/paginators-1.json - --waiters-json /path/to/waiters-2.json]" + --waiters-json /path/to/waiters-2.json + --endpoint-bdd-json /path/to/endpoint-bdd-1.json]" * */ public class UpdateServiceMain extends Cli { @@ -50,7 +51,10 @@ private UpdateServiceMain() { optionalOption("paginators-json", "The paginators-1.json file for the service."), optionalOption("waiters-json", "The waiters-2.json file for the service."), optionalOption("endpoint-rule-set-json", "The endpoint-rule-set.json file for the service."), - optionalOption("endpoint-tests-json", "The endpoint-tests.json file for the service.")); + optionalOption("endpoint-tests-json", "The endpoint-tests.json file for the service."), + optionalOption("endpoint-bdd-json", "The endpoint-bdd-1.json file for the service. Only copied for a " + + "service that already has one, so that BDD endpoint resolution " + + "stays opt-in per service.")); } public static void main(String[] args) { @@ -71,6 +75,7 @@ private static class ServiceUpdater { private final Path waitersJson; private final Path endpointRuleSetJson; private final Path endpointTestsJson; + private final Path endpointBddJson; private ServiceUpdater(CommandLine commandLine) { this.mavenProjectRoot = Paths.get(commandLine.getOptionValue("maven-project-root").trim()); @@ -81,6 +86,7 @@ private ServiceUpdater(CommandLine commandLine) { this.waitersJson = optionalPath(commandLine.getOptionValue("waiters-json")); this.endpointRuleSetJson = optionalPath(commandLine.getOptionValue("endpoint-rule-set-json")); this.endpointTestsJson = optionalPath(commandLine.getOptionValue("endpoint-tests-json")); + this.endpointBddJson = optionalPath(commandLine.getOptionValue("endpoint-bdd-json")); } private Path optionalPath(String path) { @@ -101,6 +107,7 @@ public void run() throws Exception { copyFile(waitersJson, codegenFileLocation.resolve("waiters-2.json")); copyFile(endpointRuleSetJson, codegenFileLocation.resolve("endpoint-rule-set.json")); copyFile(endpointTestsJson, codegenFileLocation.resolve("endpoint-tests.json")); + copyFileIfAlreadyPresent(endpointBddJson, codegenFileLocation.resolve("endpoint-bdd-1.json")); } private Path codegenFileLocation(String serviceModuleName, String serviceId) { @@ -132,5 +139,22 @@ private void copyFile(Path source, Path destination) throws IOException { FileUtils.copyFile(source.toFile(), destination.toFile()); } } + + /** + * Updates {@code destination} from {@code source} only when {@code destination} already exists, leaving a + * service that does not have the file without one. + */ + private void copyFileIfAlreadyPresent(Path source, Path destination) throws IOException { + if (source == null || !Files.isRegularFile(source)) { + return; + } + if (!Files.isRegularFile(destination)) { + log.info(() -> "Skipping " + source + " because " + destination + " does not exist. Add the file to opt " + + "this service in."); + return; + } + log.info(() -> "Copying " + source + " to " + destination); + FileUtils.copyFile(source.toFile(), destination.toFile()); + } } } diff --git a/release-scripts/src/test/java/software/amazon/awssdk/release/UpdateServiceMainTest.java b/release-scripts/src/test/java/software/amazon/awssdk/release/UpdateServiceMainTest.java new file mode 100644 index 000000000000..c6dae04432cc --- /dev/null +++ b/release-scripts/src/test/java/software/amazon/awssdk/release/UpdateServiceMainTest.java @@ -0,0 +1,220 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.release; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers how {@link UpdateServiceMain} copies model files into a service's {@code codegen-resources} directory, and in + * particular the gate on {@code endpoint-bdd-1.json}. + * + *

Whether a service resolves endpoints through the BDD provider is decided by whether {@code endpoint-bdd-1.json} is + * checked in. Upstream publishes the model for every service that has one, so copying it unconditionally would move + * every service onto the BDD provider on the first release that carried it. Adding the file by hand is the act of + * opting a service in; the release script only keeps an existing one up to date. + * + *

Driven through {@code main} rather than by calling the copy helper directly, so the option registration and the + * destination path are covered too. An unregistered option would leave the argument silently ignored, which no test of + * the helper alone would catch. + */ +class UpdateServiceMainTest { + + @Test + void endpointBddJson_notAlreadyPresent_isNotCopied(@TempDir Path tmp) throws Exception { + Fixture fixture = new Fixture(tmp); + + fixture.run(); + + assertThat(fixture.destination("endpoint-bdd-1.json")) + .as("a service without a checked-in BDD model must not be opted in by a release") + .doesNotExist(); + } + + @Test + void endpointBddJson_alreadyPresent_isOverwritten(@TempDir Path tmp) throws Exception { + Fixture fixture = new Fixture(tmp); + fixture.writeDestination("endpoint-bdd-1.json", "{\"stale\": true}"); + + fixture.run(); + + assertThat(fixture.destination("endpoint-bdd-1.json")).hasContent(Fixture.BDD_CONTENT); + } + + /** + * An empty file still counts as present. It is a checked-in file, so the service is opted in, and refusing to + * refresh it would leave it stale forever. + */ + @Test + void endpointBddJson_presentButEmpty_isStillOverwritten(@TempDir Path tmp) throws Exception { + Fixture fixture = new Fixture(tmp); + fixture.writeDestination("endpoint-bdd-1.json", ""); + + fixture.run(); + + assertThat(fixture.destination("endpoint-bdd-1.json")).hasContent(Fixture.BDD_CONTENT); + } + + /** + * The gate is on the destination, not the source, so omitting the argument leaves an opted-in service's model + * alone rather than deleting or emptying it. + */ + @Test + void endpointBddJson_argumentOmitted_leavesAnExistingModelAlone(@TempDir Path tmp) throws Exception { + Fixture fixture = new Fixture(tmp); + fixture.writeDestination("endpoint-bdd-1.json", "{\"kept\": true}"); + + fixture.withoutEndpointBddArgument().run(); + + assertThat(fixture.destination("endpoint-bdd-1.json")).hasContent("{\"kept\": true}"); + } + + @Test + void endpointBddJson_isOptionalAndAbsenceIsNotAnError(@TempDir Path tmp) throws Exception { + Fixture fixture = new Fixture(tmp); + + fixture.withoutEndpointBddArgument().run(); + + assertThat(fixture.destination("service-2.json")).hasContent(Fixture.SERVICE_CONTENT); + assertThat(fixture.destination("endpoint-bdd-1.json")).doesNotExist(); + } + + /** + * The other model files keep their unconditional behaviour. A service legitimately gains waiters or endpoint tests + * for the first time, and those must land on the release that carries them. + */ + @Test + void otherModelFiles_areCopiedEvenWhenNotAlreadyPresent(@TempDir Path tmp) throws Exception { + Fixture fixture = new Fixture(tmp); + + fixture.run(); + + assertThat(fixture.destination("service-2.json")).hasContent(Fixture.SERVICE_CONTENT); + assertThat(fixture.destination("paginators-1.json")).exists(); + assertThat(fixture.destination("waiters-2.json")).exists(); + assertThat(fixture.destination("endpoint-rule-set.json")).exists(); + assertThat(fixture.destination("endpoint-tests.json")).exists(); + } + + /** + * DynamoDB's models live in a nested directory, so this checks the gate is applied against the same resolved + * destination the copy uses rather than the module root. + */ + @Test + void endpointBddJson_gateAppliesToTheResolvedNestedDestination(@TempDir Path tmp) throws Exception { + Fixture fixture = new Fixture(tmp, "dynamodb", "DynamoDB", "dynamodb"); + fixture.writeDestination("endpoint-bdd-1.json", "{\"stale\": true}"); + + fixture.run(); + + assertThat(fixture.destination("endpoint-bdd-1.json")).hasContent(Fixture.BDD_CONTENT); + assertThat(tmp.resolve("services/dynamodb/src/main/resources/codegen-resources/endpoint-bdd-1.json")) + .as("the nested service must not also get a copy at the module root") + .doesNotExist(); + } + + private static final class Fixture { + private static final String BDD_CONTENT = "{\"version\": \"1.0\", \"nodes\": \"AAAA\"}"; + private static final String SERVICE_CONTENT = "{\"metadata\": {}}"; + + private final Path projectRoot; + private final Path sourceDir; + private final Path destinationDir; + private final String serviceModuleName; + private final String serviceId; + private boolean includeEndpointBddArgument = true; + + private Fixture(Path tmp) throws IOException { + this(tmp, "myservice", "MyService", null); + } + + private Fixture(Path tmp, String serviceModuleName, String serviceId, String nestedDir) throws IOException { + this.projectRoot = tmp.resolve("project"); + this.sourceDir = tmp.resolve("source"); + this.serviceModuleName = serviceModuleName; + this.serviceId = serviceId; + + Path codegenResources = projectRoot.resolve("services") + .resolve(serviceModuleName) + .resolve("src") + .resolve("main") + .resolve("resources") + .resolve("codegen-resources"); + this.destinationDir = nestedDir == null ? codegenResources : codegenResources.resolve(nestedDir); + Files.createDirectories(destinationDir); + Files.createDirectories(sourceDir); + + write(sourceDir.resolve("service-2.json"), SERVICE_CONTENT); + write(sourceDir.resolve("paginators-1.json"), "{\"pagination\": {}}"); + write(sourceDir.resolve("waiters-2.json"), "{\"waiters\": {}}"); + write(sourceDir.resolve("endpoint-rule-set.json"), "{\"version\": \"1.0\"}"); + write(sourceDir.resolve("endpoint-tests.json"), "{\"testCases\": []}"); + write(sourceDir.resolve("endpoint-bdd-1.json"), BDD_CONTENT); + } + + private Fixture withoutEndpointBddArgument() { + this.includeEndpointBddArgument = false; + return this; + } + + private void writeDestination(String name, String content) throws IOException { + write(destinationDir.resolve(name), content); + } + + private Path destination(String name) { + return destinationDir.resolve(name); + } + + private void run() { + List args = new ArrayList<>(); + args.add("--maven-project-root"); + args.add(projectRoot.toString()); + args.add("--service-module-name"); + args.add(serviceModuleName); + args.add("--service-id"); + args.add(serviceId); + args.add("--service-json"); + args.add(sourceDir.resolve("service-2.json").toString()); + args.add("--paginators-json"); + args.add(sourceDir.resolve("paginators-1.json").toString()); + args.add("--waiters-json"); + args.add(sourceDir.resolve("waiters-2.json").toString()); + args.add("--endpoint-rule-set-json"); + args.add(sourceDir.resolve("endpoint-rule-set.json").toString()); + args.add("--endpoint-tests-json"); + args.add(sourceDir.resolve("endpoint-tests.json").toString()); + if (includeEndpointBddArgument) { + args.add("--endpoint-bdd-json"); + args.add(sourceDir.resolve("endpoint-bdd-1.json").toString()); + } + + UpdateServiceMain.main(args.toArray(new String[0])); + } + + private static void write(Path path, String content) throws IOException { + Files.createDirectories(path.getParent()); + Files.write(path, content.getBytes(UTF_8)); + } + } +}