[cli][docker] Better expose version/sha information of builds (#5736)

* [cli] Some CLI improvements…

* Introduce --version
* Introduce --help
* Add --sha to version command for short SHA display
* Output Version and SHA details
* In new --version output, display repo and doc site

Additional cleanup to suppress warnings and code quality.

* [docker] Adds labels for metadata

This adds image labels to store metadata on the online and cli docker
images, using standard labels:

* org.opencontainers.image.created
* org.opencontainers.image.revision
* org.opencontainers.image.title
* org.opencontainers.image.version

These can be inspected via 'docker inspect IMAGE_NAME' and may be useful
in tooling/automation or bug reports submitted by users.

For more details on these labels, see:
https://github.com/opencontainers/image-spec/blob/master/annotations.md

* Include version --full for equiv to --version
This commit is contained in:
Jim Schubert 2020-04-01 23:05:25 -04:00 committed by GitHub
parent 4623ec86d3
commit e14e5fccf3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 299 additions and 66 deletions

View File

@ -178,9 +178,33 @@ after_success:
fi;
fi;
## docker: build and push openapi-generator-online to DockerHub
- if [ $DOCKER_HUB_USERNAME ]; then echo "$DOCKER_HUB_PASSWORD" | docker login --username=$DOCKER_HUB_USERNAME --password-stdin && docker build -t $DOCKER_GENERATOR_IMAGE_NAME ./modules/openapi-generator-online && if [ ! -z "$TRAVIS_TAG" ]; then docker tag $DOCKER_GENERATOR_IMAGE_NAME:latest $DOCKER_GENERATOR_IMAGE_NAME:$TRAVIS_TAG; fi && if [ ! -z "$TRAVIS_TAG" ] || [ "$TRAVIS_BRANCH" = "master" ]; then docker push $DOCKER_GENERATOR_IMAGE_NAME && echo "Pushed to $DOCKER_GENERATOR_IMAGE_NAME"; fi; fi
- if [ $DOCKER_HUB_USERNAME ]; then
echo "$DOCKER_HUB_PASSWORD" | docker login --username=$DOCKER_HUB_USERNAME --password-stdin;
export cli_version=$(\mvn -o org.apache.maven.plugins:maven-help-plugin:2.1.1:evaluate -Dexpression=project.version | grep -v '\[');
export build_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ");
docker build --label=org.opencontainers.image.created=$build_date --label=org.opencontainers.image.title=openapi-generator-online --label=org.opencontainers.image.revision=$TRAVIS_COMMIT --label=org.opencontainers.image.version=$cli_version -t $DOCKER_GENERATOR_IMAGE_NAME ./modules/openapi-generator-online;
if [ ! -z "$TRAVIS_TAG" ]; then
docker tag $DOCKER_GENERATOR_IMAGE_NAME:latest $DOCKER_GENERATOR_IMAGE_NAME:$TRAVIS_TAG;
fi;
if [ ! -z "$TRAVIS_TAG" ] || [ "$TRAVIS_BRANCH" = "master" ]; then
docker push $DOCKER_GENERATOR_IMAGE_NAME && echo "Pushed to $DOCKER_GENERATOR_IMAGE_NAME";
fi;
fi;
## docker: build cli image and push to Docker Hub
- if [ $DOCKER_HUB_USERNAME ]; then echo "$DOCKER_HUB_PASSWORD" | docker login --username=$DOCKER_HUB_USERNAME --password-stdin && cp docker-entrypoint.sh ./modules/openapi-generator-cli && docker build -t $DOCKER_CODEGEN_CLI_IMAGE_NAME ./modules/openapi-generator-cli && if [ ! -z "$TRAVIS_TAG" ]; then docker tag $DOCKER_CODEGEN_CLI_IMAGE_NAME:latest $DOCKER_CODEGEN_CLI_IMAGE_NAME:$TRAVIS_TAG; fi && if [ ! -z "$TRAVIS_TAG" ] || [ "$TRAVIS_BRANCH" = "master" ]; then docker push $DOCKER_CODEGEN_CLI_IMAGE_NAME && echo "Pushed to $DOCKER_CODEGEN_CLI_IMAGE_NAME"; fi; fi
- if [ $DOCKER_HUB_USERNAME ]; then
echo "$DOCKER_HUB_PASSWORD" | docker login --username=$DOCKER_HUB_USERNAME --password-stdin;
cp docker-entrypoint.sh ./modules/openapi-generator-cli;
export cli_version=$(\mvn -o org.apache.maven.plugins:maven-help-plugin:2.1.1:evaluate -Dexpression=project.version | grep -v '\[');
export build_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ");
docker build --label=org.opencontainers.image.created=$build_date --label=org.opencontainers.image.title=openapi-generator-cli --label=org.opencontainers.image.revision=$TRAVIS_COMMIT --label=org.opencontainers.image.version=$cli_version -t $DOCKER_CODEGEN_CLI_IMAGE_NAME ./modules/openapi-generator-cli;
if [ ! -z "$TRAVIS_TAG" ]; then
docker tag $DOCKER_CODEGEN_CLI_IMAGE_NAME:latest $DOCKER_CODEGEN_CLI_IMAGE_NAME:$TRAVIS_TAG;
fi;
if [ ! -z "$TRAVIS_TAG" ] || [ "$TRAVIS_BRANCH" = "master" ]; then
docker push $DOCKER_CODEGEN_CLI_IMAGE_NAME;
echo "Pushed to $DOCKER_CODEGEN_CLI_IMAGE_NAME";
fi;
fi;
## publish latest website, variables below are secure environment variables which are unavailable to PRs from forks.
- if [ "$TRAVIS_BRANCH" = "master" ] && [ -z $TRAVIS_TAG ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
cd website;

View File

@ -25,6 +25,22 @@ The most commonly used openapi-generator-cli commands are:
See 'openapi-generator-cli help <command>' for more information on a specific
command.
```
## version
The version command provides version information, returning either the semver version by default or the git sha when passed `--sha`.
```bash
NAME
openapi-generator-cli version - Show version information
SYNOPSIS
openapi-generator-cli version [--sha]
OPTIONS
--sha
Git commit SHA version
```

View File

@ -0,0 +1,9 @@
package org.openapitools.codegen;
public class Constants {
private Constants(){ }
public static final String CLI_NAME = "openapi-generator-cli";
public static final String GIT_REPO = "https://github.com/openapitools/openapi-generator";
public static final String SITE = "https://openapi-generator.tech/";
}

View File

@ -18,7 +18,6 @@
package org.openapitools.codegen;
import io.airlift.airline.Cli;
import io.airlift.airline.Help;
import io.airlift.airline.ParseArgumentsUnexpectedException;
import io.airlift.airline.ParseOptionMissingException;
import io.airlift.airline.ParseOptionMissingValueException;
@ -26,30 +25,31 @@ import org.openapitools.codegen.cmd.*;
import java.util.Locale;
import static org.openapitools.codegen.Constants.CLI_NAME;
/**
* User: lanwen Date: 24.03.15 Time: 17:56
* <p>
* Command line interface for OpenAPI Generator use `openapi-generator-cli.jar help` for more info
*
* @since 2.1.3-M1
*/
public class OpenAPIGenerator {
public static void main(String[] args) {
String version = Version.readVersionFromResources();
Cli.CliBuilder<Runnable> builder =
Cli.<Runnable>builder("openapi-generator-cli")
BuildInfo buildInfo = new BuildInfo();
Cli.CliBuilder<OpenApiGeneratorCommand> builder =
Cli.<OpenApiGeneratorCommand>builder(CLI_NAME)
.withDescription(
String.format(
Locale.ROOT,
"OpenAPI generator CLI (version %s).",
version))
.withDefaultCommand(ListGenerators.class)
"OpenAPI Generator CLI %s (%s).",
buildInfo.getVersion(),
buildInfo.getSha()))
.withDefaultCommand(HelpCommand.class)
.withCommands(
ListGenerators.class,
Generate.class,
Meta.class,
Help.class,
HelpCommand.class,
ConfigHelp.class,
Validate.class,
Version.class,
@ -60,7 +60,7 @@ public class OpenAPIGenerator {
try {
builder.build().parse(args).run();
// If CLI is run without a command, consider this an error. This exists after initial parse/run
// If CLI runs without a command, consider this an error. This exists after initial parse/run
// so we can present the configured "default command".
// We can check against empty args because unrecognized arguments/commands result in an exception.
// This is useful to exit with status 1, for example, so that misconfigured scripts fail fast.
@ -71,10 +71,10 @@ public class OpenAPIGenerator {
System.exit(1);
}
} catch (ParseArgumentsUnexpectedException e) {
System.err.printf(Locale.ROOT,"[error] %s%n%nSee 'openapi-generator help' for usage.%n", e.getMessage());
System.err.printf(Locale.ROOT, "[error] %s%n%nSee '%s help' for usage.%n", e.getMessage(), CLI_NAME);
System.exit(1);
} catch (ParseOptionMissingException | ParseOptionMissingValueException e) {
System.err.printf(Locale.ROOT,"[error] %s%n", e.getMessage());
System.err.printf(Locale.ROOT, "[error] %s%n", e.getMessage());
System.exit(1);
}
}

View File

@ -0,0 +1,90 @@
package org.openapitools.codegen.cmd;
import java.io.IOException;
import java.io.InputStream;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Locale;
import java.util.Properties;
import static org.openapitools.codegen.Constants.*;
/**
* Presents build-time information
*/
@SuppressWarnings({"java:S108"})
public class BuildInfo {
private static final String VERSION_PLACEHOLDER = "${project.version}";
private static final String UNSET = "unset";
private static final String UNKNOWN = "unknown";
private static final Properties properties = new Properties();
static {
try (InputStream is = BuildInfo.class.getResourceAsStream("/version.properties")) {
Properties versionProps = new Properties();
versionProps.load(is);
properties.putAll(versionProps);
} catch (IOException ignored) {
}
try (InputStream is = BuildInfo.class.getResourceAsStream("/openapi-generator-git.properties")) {
Properties gitProps = new Properties();
gitProps.load(is);
properties.putAll(gitProps);
} catch (IOException ignored) {
}
}
/**
* Gets the version of the toolset.
*
* @return A semver string
*/
public String getVersion() {
String version = (String) properties.getOrDefault("version", UNKNOWN);
if (VERSION_PLACEHOLDER.equals(version)) {
return UNSET;
} else {
return version;
}
}
/**
* Gets the git commit SHA1 hash. Useful for differentiating between SNAPSHOT builds.
*
* @return A short git SHA
*/
public String getSha() {
return (String) properties.getOrDefault("git.commit.id.abbrev", UNKNOWN);
}
/**
* Gets the time when this tool was built.
*
* @return The time as {@link OffsetDateTime}, or {@link OffsetDateTime#MIN} if metadata cannot be parsed.
*/
public OffsetDateTime getBuildTime() {
try {
String time = (String) properties.getOrDefault("git.build.time", "");
return OffsetDateTime.parse(time, DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ROOT));
} catch (DateTimeParseException e) {
return OffsetDateTime.MIN;
}
}
/**
* Gets the full version display text, as one would expect from a '--version' CLI option
*
* @return Human-readable version display information
*/
public String versionDisplayText() {
StringBuilder sb = new StringBuilder(CLI_NAME);
sb.append(" ").append(this.getVersion()).append(System.lineSeparator());
sb.append(" commit : ").append(this.getSha()).append(System.lineSeparator());
sb.append(" built : ").append(this.getBuildTime().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)).append(System.lineSeparator());
sb.append(" source : ").append(GIT_REPO).append(System.lineSeparator());
sb.append(" docs : ").append(SITE).append(System.lineSeparator());
return sb.toString();
}
}

View File

@ -36,8 +36,9 @@ import java.util.concurrent.Callable;
import static com.google.common.collect.Lists.newArrayList;
import static io.airlift.airline.ParserUtil.createInstance;
@SuppressWarnings({"java:S106"})
@Command(name = "completion", description = "Complete commands (for using in tooling such as Bash Completions).", hidden = true)
public class CompletionCommand
public class CompletionCommand extends OpenApiGeneratorCommand
implements Runnable, Callable<Void> {
private static final Map<Context, Class<? extends Suggester>> BUILTIN_SUGGESTERS = ImmutableMap.<Context, Class<? extends Suggester>>builder()
.put(Context.GLOBAL, GlobalSuggester.class)
@ -95,7 +96,7 @@ public class CompletionCommand
}
@Override
public void run() {
void execute() {
System.out.println(Joiner.on("\n").join(generateSuggestions()));
}
}

View File

@ -39,9 +39,9 @@ import java.util.stream.Collectors;
import static org.apache.commons.lang3.StringEscapeUtils.escapeHtml4;
import static org.apache.commons.lang3.StringUtils.isEmpty;
@SuppressWarnings("unused")
@SuppressWarnings({"unused","java:S106"})
@Command(name = "config-help", description = "Config help for chosen lang")
public class ConfigHelp implements Runnable {
public class ConfigHelp extends OpenApiGeneratorCommand {
private static final Logger LOGGER = LoggerFactory.getLogger(Generate.class);
@ -91,7 +91,7 @@ public class ConfigHelp implements Runnable {
private String newline = System.lineSeparator();
@Override
public void run() {
public void execute() {
if (isEmpty(generatorName)) {
LOGGER.error("[error] A generator name (--generator-name / -g) is required.");
System.exit(1);
@ -320,6 +320,7 @@ public class ConfigHelp implements Runnable {
}
}
@SuppressWarnings({"java:S1117"})
private void generatePlainTextHelp(StringBuilder sb, CodegenConfig config) {
sb.append(newline).append("CONFIG OPTIONS");
if (Boolean.TRUE.equals(namedHeader)) {
@ -418,6 +419,7 @@ public class ConfigHelp implements Runnable {
}
}
@SuppressWarnings({"java:S1117"})
private void writePlainTextFromMap(
StringBuilder sb,
Map<String, String> map,
@ -449,6 +451,7 @@ public class ConfigHelp implements Runnable {
}
}
@SuppressWarnings({"java:S1117"})
private void writePlainTextFromArray(StringBuilder sb, String[] arr, String optIndent) {
if (arr.length > 0) {
// target a width of 20, then take the max up to 40.

View File

@ -34,8 +34,9 @@ import org.openapitools.codegen.config.CodegenConfigurator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@SuppressWarnings({"java:S106"})
@Command(name = "generate", description = "Generate code with the specified generator.")
public class Generate implements Runnable {
public class Generate extends OpenApiGeneratorCommand {
CodegenConfigurator configurator;
Generator generator;
@ -244,7 +245,7 @@ public class Generate implements Runnable {
private Boolean minimalUpdate;
@Override
public void run() {
public void execute() {
if (logToStderr != null) {
LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
Stream.of(Logger.ROOT_LOGGER_NAME, "io.swagger", "org.openapitools")

View File

@ -50,9 +50,9 @@ import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@SuppressWarnings({"unused", "MismatchedQueryAndUpdateOfCollection"})
@SuppressWarnings({"unused", "MismatchedQueryAndUpdateOfCollection", "java:S106"})
@Command(name = "batch", description = "Generate code in batch via external configs.", hidden = true)
public class GenerateBatch implements Runnable {
public class GenerateBatch extends OpenApiGeneratorCommand {
private static final Logger LOGGER = LoggerFactory.getLogger(GenerateBatch.class);
@ -89,7 +89,7 @@ public class GenerateBatch implements Runnable {
* @see Thread#run()
*/
@Override
public void run() {
public void execute() {
if (configs.size() < 1) {
LOGGER.error("No configuration file inputs specified");
System.exit(1);

View File

@ -0,0 +1,13 @@
package org.openapitools.codegen.cmd;
import io.airlift.airline.Option;
import static io.airlift.airline.OptionType.GLOBAL;
public class GlobalOptions {
@Option(type = GLOBAL, name = "--version", description = "Display full version output", hidden = true)
public boolean version;
@Option(type = GLOBAL, name = "--help", description = "Display help about the tool", hidden = true)
public boolean help;
}

View File

@ -0,0 +1,18 @@
package org.openapitools.codegen.cmd;
import io.airlift.airline.Command;
import io.airlift.airline.Help;
import javax.inject.Inject;
@Command(name = "help", description = "Display help information about openapi-generator")
public class HelpCommand extends OpenApiGeneratorCommand {
@Inject
public Help help;
@Override
public void execute() {
help.call();
}
}

View File

@ -16,8 +16,9 @@ import java.util.*;
import java.util.stream.Collectors;
// NOTE: List can later have subcommands such as list languages, list types, list frameworks, etc.
@SuppressWarnings({"java:S106"})
@Command(name = "list", description = "Lists the available generators")
public class ListGenerators implements Runnable {
public class ListGenerators extends OpenApiGeneratorCommand {
@Option(name = {"-s", "--short" }, description = "shortened output (suitable for scripting)")
private Boolean shortened = false;
@ -34,7 +35,7 @@ public class ListGenerators implements Runnable {
private String include = "stable,beta,experimental";
@Override
public void run() {
public void execute() {
List<CodegenConfig> generators = new ArrayList<>();
List<Stability> stabilities = Arrays.asList(Stability.values());

View File

@ -49,7 +49,7 @@ import ch.lambdaj.function.convert.Converter;
@Command(name = "meta", description = "MetaGenerator. Generator for creating a new template set "
+ "and configuration for Codegen. The output will be based on the language you "
+ "specify, and includes default templates to include.")
public class Meta implements Runnable {
public class Meta extends OpenApiGeneratorCommand {
private static final Logger LOGGER = LoggerFactory.getLogger(Meta.class);
@ -80,7 +80,7 @@ public class Meta implements Runnable {
private String language = "java";
@Override
public void run() {
public void execute() {
final File targetDir = new File(outputFolder);
LOGGER.info("writing to folder [{}]", targetDir.getAbsolutePath());
@ -110,7 +110,7 @@ public class Meta implements Runnable {
new SupportingFile("myFile.template", String.join(File.separator, "src", "main", "resources", name), "myFile.mustache"),
new SupportingFile("services.mustache", "src/main/resources/META-INF/services", CodegenConfig.class.getCanonicalName()));
String currentVersion = Version.readVersionFromResources();
String currentVersion = buildInfo.getVersion();
Map<String, Object> data =
new ImmutableMap.Builder<String, Object>()

View File

@ -0,0 +1,39 @@
package org.openapitools.codegen.cmd;
import io.airlift.airline.Help;
import io.airlift.airline.model.GlobalMetadata;
import javax.inject.Inject;
@SuppressWarnings({"java:S106"})
public abstract class OpenApiGeneratorCommand implements Runnable {
@Inject
public GlobalOptions globalOptions = new GlobalOptions();
@Inject
public GlobalMetadata global;
protected BuildInfo buildInfo = new BuildInfo();
@Override
public void run() {
if (globalOptions.version) {
System.out.println(buildInfo.versionDisplayText());
return;
}
if (globalOptions.help) {
Help help = new Help();
help.global = global;
help.call();
return;
}
execute();
}
/**
* Logic to be executed by implementing commands
*/
abstract void execute();
}

View File

@ -33,8 +33,9 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
@SuppressWarnings({"unused","java:S106"})
@Command(name = "validate", description = "Validate specification")
public class Validate implements Runnable {
public class Validate extends OpenApiGeneratorCommand {
@Option(name = {"-i", "--input-spec"}, title = "spec file", required = true,
description = "location of the OpenAPI spec, as URL or file (required)")
@ -44,7 +45,7 @@ public class Validate implements Runnable {
private Boolean recommend;
@Override
public void run() {
public void execute() {
System.out.println("Validating spec (" + spec + ")");
ParseOptions options = new ParseOptions();
options.setResolve(true);
@ -57,7 +58,9 @@ public class Validate implements Runnable {
OpenAPI specification = result.getOpenAPI();
RuleConfiguration ruleConfiguration = new RuleConfiguration();
ruleConfiguration.setEnableRecommendations(recommend != null ? recommend : false);
if (recommend != null) ruleConfiguration.setEnableRecommendations(recommend);
else ruleConfiguration.setEnableRecommendations(false);
OpenApiEvaluator evaluator = new OpenApiEvaluator(ruleConfiguration);
ValidationResult validationResult = evaluator.validate(specification);

View File

@ -17,45 +17,30 @@
package org.openapitools.codegen.cmd;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import io.airlift.airline.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.airlift.airline.Option;
@Command(name = "version", description = "Show version information")
public class Version implements Runnable {
@SuppressWarnings({"unused", "java:S106"})
@Command(name = "version", description = "Show version information used in tooling")
public class Version extends OpenApiGeneratorCommand {
private static final Logger LOGGER = LoggerFactory.getLogger(Meta.class);
@Option(name = {"--sha"}, description = "Git commit SHA version")
private Boolean sha;
private static final String VERSION_PLACEHOLDER = "${project.version}";
private static final String UNREADABLE_VERSION = "unreadable";
private static final String UNSET_VERSION = "unset";
private static final String UNKNOWN_VERSION = "unknown";
public static String readVersionFromResources() {
Properties versionProperties = new Properties();
try (InputStream is = Version.class.getResourceAsStream("/version.properties")) {
versionProperties.load(is);
} catch (IOException ex) {
LOGGER.error("Error loading version properties", ex);
return UNREADABLE_VERSION;
}
String version = versionProperties.getProperty("version", UNKNOWN_VERSION).trim();
if (VERSION_PLACEHOLDER.equals(version)) {
return UNSET_VERSION;
} else {
return version;
}
}
@Option(name = {"--full"}, description = "Full version details")
private Boolean full;
@Override
public void run() {
String version = readVersionFromResources();
public void execute() {
String version;
if (Boolean.TRUE.equals(full)) {
version = buildInfo.versionDisplayText();
} else if (Boolean.TRUE.equals(sha)) {
version = buildInfo.getSha();
} else {
version = buildInfo.getVersion();
}
System.out.println(version);
}

View File

@ -43,6 +43,30 @@
<directory>target</directory>
<finalName>${project.artifactId}-${project.version}</finalName>
<plugins>
<plugin>
<groupId>pl.project13.maven</groupId>
<artifactId>git-commit-id-plugin</artifactId>
<executions>
<execution>
<id>get-the-git-infos</id>
<goals>
<goal>revision</goal>
</goals>
<phase>initialize</phase>
</execution>
</executions>
<configuration>
<generateGitPropertiesFile>true</generateGitPropertiesFile>
<generateGitPropertiesFilename>${project.build.outputDirectory}/openapi-generator-git.properties</generateGitPropertiesFilename>
<!-- REVIEWER NOTE: Never allow external contributors to change these without full clarification. -->
<includeOnlyProperties>
<includeOnlyProperty>^git.build.(time|version)$</includeOnlyProperty>
<includeOnlyProperty>^git.commit.id.(abbrev|full)$</includeOnlyProperty>
</includeOnlyProperties>
<commitIdGenerationMode>full</commitIdGenerationMode>
<dotGitDirectory>${project.parent.basedir}${file.separator}.git</dotGitDirectory>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>

View File

@ -440,6 +440,11 @@
<artifactId>maven-checkstyle-plugin</artifactId>
<version>${checkstyle.plugin.version}</version>
</plugin>
<plugin>
<groupId>pl.project13.maven</groupId>
<artifactId>git-commit-id-plugin</artifactId>
<version>${git.commit.id.plugin.version}</version>
</plugin>
</plugins>
</pluginManagement>
</build>
@ -1616,5 +1621,6 @@
<pmd.plugin.version>3.12.0</pmd.plugin.version>
<violations-maven.plugin.version>1.34</violations-maven.plugin.version>
<checkstyle.plugin.version>3.1.0</checkstyle.plugin.version>
<git.commit.id.plugin.version>4.0.0</git.commit.id.plugin.version>
</properties>
</project>