Tanzu Changes

This commit is contained in:
pratik
2025-10-20 23:25:13 -05:00
parent d81e80f273
commit b7405c644b
4 changed files with 371 additions and 27 deletions

142
README.md
View File

@@ -26,9 +26,9 @@ java -jar build/libs/cf-uploader-1.0.0.jar
The application will start on `http://localhost:8080` The application will start on `http://localhost:8080`
## API Endpoint ## API Endpoints
### Deploy Application to Cloud Foundry ### 1. Deploy Application to Cloud Foundry
**Endpoint:** `POST /api/cf/deploy` **Endpoint:** `POST /api/cf/deploy`
@@ -39,7 +39,7 @@ The application will start on `http://localhost:8080`
- `jarFile` (file): The Java application JAR file - `jarFile` (file): The Java application JAR file
- `manifest` (file): Cloud Foundry manifest.yml file - `manifest` (file): Cloud Foundry manifest.yml file
### Sample cURL Request #### Sample cURL Request
```bash ```bash
curl -X POST http://localhost:8080/api/cf/deploy \ curl -X POST http://localhost:8080/api/cf/deploy \
@@ -56,7 +56,7 @@ curl -X POST http://localhost:8080/api/cf/deploy \
-F 'manifest=@/path/to/manifest.yml' -F 'manifest=@/path/to/manifest.yml'
``` ```
### Sample Request with Skip SSL Validation #### Sample Request with Skip SSL Validation
```bash ```bash
curl -X POST http://localhost:8080/api/cf/deploy \ curl -X POST http://localhost:8080/api/cf/deploy \
@@ -73,6 +73,115 @@ curl -X POST http://localhost:8080/api/cf/deploy \
-F 'manifest=@./manifest.yml' -F 'manifest=@./manifest.yml'
``` ```
---
### 2. List Applications
**Endpoint:** `POST /api/cf/apps`
**Content-Type:** `application/json`
Lists all applications in the specified organization and space.
#### Sample cURL Request
```bash
curl -X POST http://localhost:8080/api/cf/apps \
-H "Content-Type: application/json" \
-d '{
"apiEndpoint": "https://api.cf.example.com",
"username": "your-username",
"password": "your-password",
"organization": "your-org",
"space": "your-space",
"appName": "",
"skipSslValidation": false
}'
```
---
### 3. List Routes
**Endpoint:** `POST /api/cf/routes`
**Content-Type:** `application/json`
Lists all routes in the specified organization and space.
#### Sample cURL Request
```bash
curl -X POST http://localhost:8080/api/cf/routes \
-H "Content-Type: application/json" \
-d '{
"apiEndpoint": "https://api.cf.example.com",
"username": "your-username",
"password": "your-password",
"organization": "your-org",
"space": "your-space",
"appName": "",
"skipSslValidation": false
}'
```
---
### 4. Get Application Details
**Endpoint:** `POST /api/cf/app/{appName}`
**Content-Type:** `application/json`
Gets detailed information about a specific application.
#### Sample cURL Request
```bash
curl -X POST http://localhost:8080/api/cf/app/my-app \
-H "Content-Type: application/json" \
-d '{
"apiEndpoint": "https://api.cf.example.com",
"username": "your-username",
"password": "your-password",
"organization": "your-org",
"space": "your-space",
"appName": "",
"skipSslValidation": false
}'
```
---
### 5. Get Application Logs
**Endpoint:** `POST /api/cf/logs/{appName}?recent=true`
**Content-Type:** `application/json`
**Query Parameters:**
- `recent` (optional, default: `true`): If true, gets recent logs; if false, tails logs
Gets logs for a specific application.
#### Sample cURL Request (Recent Logs)
```bash
curl -X POST "http://localhost:8080/api/cf/logs/my-app?recent=true" \
-H "Content-Type: application/json" \
-d '{
"apiEndpoint": "https://api.cf.example.com",
"username": "your-username",
"password": "your-password",
"organization": "your-org",
"space": "your-space",
"appName": "",
"skipSslValidation": false
}'
```
---
### Sample Manifest File (manifest.yml) ### Sample Manifest File (manifest.yml)
```yaml ```yaml
@@ -88,7 +197,9 @@ applications:
SPRING_PROFILES_ACTIVE: production SPRING_PROFILES_ACTIVE: production
``` ```
## Request Parameters ## Common Request Parameters
All endpoints require the following CF credentials and target information:
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
@@ -97,9 +208,11 @@ applications:
| `password` | String | Yes | CF password | | `password` | String | Yes | CF password |
| `organization` | String | Yes | Target CF organization | | `organization` | String | Yes | Target CF organization |
| `space` | String | Yes | Target CF space | | `space` | String | Yes | Target CF space |
| `appName` | String | Yes | Application name | | `appName` | String | No* | Application name (required only for `/deploy` endpoint) |
| `skipSslValidation` | Boolean | Yes | Skip SSL certificate validation | | `skipSslValidation` | Boolean | Yes | Skip SSL certificate validation |
**Note:** Even though `appName` is not used by utility endpoints (`/apps`, `/routes`, `/app/{appName}`, `/logs/{appName}`), it must still be included in the JSON request body (can be empty string). The organization and space determine which apps/routes are listed or queried.
## Response Format ## Response Format
### Success Response ### Success Response
@@ -147,13 +260,16 @@ cf.cli.path=
## Features ## Features
- RESTful API for CF deployments - **Application Deployment**: Deploy JAR files to Cloud Foundry with manifest support
- Automatic CF CLI binary management (Linux, macOS, Windows) - **Application Management**: List apps, view details, and access logs
- Secure password handling (masked in logs) - **Route Management**: List all routes in your CF space
- Configurable timeout for long-running deployments - **Automatic CF CLI Management**: Bundled CF CLI binaries for Linux, macOS, and Windows
- Comprehensive error handling - **Secure Password Handling**: Passwords are masked in all log output
- Multipart file upload support up to 500MB - **Comprehensive Logging**: Detailed DEBUG-level logging for troubleshooting deployments
- Automatic cleanup of temporary files - **Configurable Timeouts**: Adjustable timeout for long-running deployments (default: 600s)
- **Large File Support**: Multipart file upload support up to 500MB
- **Automatic Cleanup**: Temporary files are automatically cleaned up after operations
- **Error Handling**: Comprehensive exception handling with detailed error messages
## Error Handling ## Error Handling

View File

@@ -42,6 +42,8 @@ task downloadCfCli {
def resourcesDir = file("$projectDir/src/main/resources/cf-cli") def resourcesDir = file("$projectDir/src/main/resources/cf-cli")
resourcesDir.mkdirs() resourcesDir.mkdirs()
// Download for all platforms (useful for local development on different OSes)
// For production Tanzu deployment, only Linux binary will be used
def downloads = [ def downloads = [
[os: 'linux', url: "https://packages.cloudfoundry.org/stable?release=linux64-binary&version=${cfCliVersion}&source=github-rel", ext: 'tgz', executable: 'cf'], [os: 'linux', url: "https://packages.cloudfoundry.org/stable?release=linux64-binary&version=${cfCliVersion}&source=github-rel", ext: 'tgz', executable: 'cf'],
[os: 'macos', url: "https://packages.cloudfoundry.org/stable?release=macosx64-binary&version=${cfCliVersion}&source=github-rel", ext: 'tgz', executable: 'cf'], [os: 'macos', url: "https://packages.cloudfoundry.org/stable?release=macosx64-binary&version=${cfCliVersion}&source=github-rel", ext: 'tgz', executable: 'cf'],
@@ -81,5 +83,42 @@ task downloadCfCli {
} }
} }
// Task to download only Linux CF CLI (for production Tanzu builds)
task downloadCfCliLinuxOnly {
group = 'build'
description = 'Downloads CF CLI binary for Linux only (production builds)'
doLast {
def cfCliVersion = '8.7.10'
def resourcesDir = file("$projectDir/src/main/resources/cf-cli")
resourcesDir.mkdirs()
def download = [os: 'linux', url: "https://packages.cloudfoundry.org/stable?release=linux64-binary&version=${cfCliVersion}&source=github-rel", ext: 'tgz', executable: 'cf']
def osDir = file("${resourcesDir}/${download.os}")
osDir.mkdirs()
def archiveFile = file("${osDir}/cf-cli.${download.ext}")
if (!archiveFile.exists()) {
println "Downloading CF CLI for Linux..."
ant.get(src: download.url, dest: archiveFile, verbose: true)
println "Extracting CF CLI for Linux..."
copy {
from tarTree(resources.gzip(archiveFile))
into osDir
}
archiveFile.delete()
println "CF CLI for Linux downloaded and extracted successfully"
} else {
println "CF CLI for Linux already exists, skipping download"
}
}
}
// Ensure CF CLI is downloaded before building // Ensure CF CLI is downloaded before building
// Use downloadCfCli for local development (all platforms)
// Use downloadCfCliLinuxOnly for production Tanzu builds (Linux only)
processResources.dependsOn downloadCfCli processResources.dependsOn downloadCfCli

View File

@@ -11,10 +11,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@RestController @RestController
@@ -60,6 +57,67 @@ public class CfDeployController {
} }
} }
@PostMapping("/apps")
public ResponseEntity<String> listApps(@RequestBody String requestJson) {
try {
log.info("Received request to list apps");
CfDeployRequest request = objectMapper.readValue(requestJson, CfDeployRequest.class);
String output = cfCliService.listApps(request);
return ResponseEntity.ok(output);
} catch (Exception e) {
log.error("Error listing apps", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to list apps: " + e.getMessage());
}
}
@PostMapping("/routes")
public ResponseEntity<String> listRoutes(@RequestBody String requestJson) {
try {
log.info("Received request to list routes");
CfDeployRequest request = objectMapper.readValue(requestJson, CfDeployRequest.class);
String output = cfCliService.listRoutes(request);
return ResponseEntity.ok(output);
} catch (Exception e) {
log.error("Error listing routes", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to list routes: " + e.getMessage());
}
}
@PostMapping("/app/{appName}")
public ResponseEntity<String> getAppDetails(
@PathVariable String appName,
@RequestBody String requestJson) {
try {
log.info("Received request to get details for app: {}", appName);
CfDeployRequest request = objectMapper.readValue(requestJson, CfDeployRequest.class);
String output = cfCliService.getAppDetails(request, appName);
return ResponseEntity.ok(output);
} catch (Exception e) {
log.error("Error getting app details for: {}", appName, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to get app details: " + e.getMessage());
}
}
@PostMapping("/logs/{appName}")
public ResponseEntity<String> getAppLogs(
@PathVariable String appName,
@RequestParam(defaultValue = "true") boolean recent,
@RequestBody String requestJson) {
try {
log.info("Received request to get {} logs for app: {}", recent ? "recent" : "tail", appName);
CfDeployRequest request = objectMapper.readValue(requestJson, CfDeployRequest.class);
String output = cfCliService.getAppLogs(request, appName, recent);
return ResponseEntity.ok(output);
} catch (Exception e) {
log.error("Error getting logs for: {}", appName, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to get app logs: " + e.getMessage());
}
}
private void validateFiles(MultipartFile jarFile, MultipartFile manifest) { private void validateFiles(MultipartFile jarFile, MultipartFile manifest) {
if (jarFile.isEmpty()) { if (jarFile.isEmpty()) {
throw new IllegalArgumentException("JAR file is empty"); throw new IllegalArgumentException("JAR file is empty");

View File

@@ -39,28 +39,41 @@ public class CfCliService {
public CfDeployResponse deployApplication(CfDeployRequest request, MultipartFile jarFile, MultipartFile manifest) { public CfDeployResponse deployApplication(CfDeployRequest request, MultipartFile jarFile, MultipartFile manifest) {
Path tempDir = null; Path tempDir = null;
try { try {
log.info("=== Starting deployment for app: {} ===", request.getAppName());
log.info("Target: {}/{}/{}", request.getApiEndpoint(), request.getOrganization(), request.getSpace());
tempDir = Files.createTempDirectory("cf-deploy-"); tempDir = Files.createTempDirectory("cf-deploy-");
log.info("Created temporary directory: {}", tempDir); log.info("Created temporary directory: {}", tempDir);
Path jarPath = tempDir.resolve(jarFile.getOriginalFilename()); Path jarPath = tempDir.resolve(jarFile.getOriginalFilename());
Path manifestPath = tempDir.resolve("manifest.yml"); Path manifestPath = tempDir.resolve("manifest.yml");
log.debug("Copying uploaded files - JAR: {}, Manifest: {}", jarFile.getOriginalFilename(), manifest.getOriginalFilename());
log.debug("JAR file size: {} bytes", jarFile.getSize());
log.debug("Manifest file size: {} bytes", manifest.getSize());
Files.copy(jarFile.getInputStream(), jarPath, StandardCopyOption.REPLACE_EXISTING); Files.copy(jarFile.getInputStream(), jarPath, StandardCopyOption.REPLACE_EXISTING);
Files.copy(manifest.getInputStream(), manifestPath, StandardCopyOption.REPLACE_EXISTING); Files.copy(manifest.getInputStream(), manifestPath, StandardCopyOption.REPLACE_EXISTING);
log.info("Copied JAR and manifest files to temporary directory"); log.info("Successfully copied JAR: {} ({} bytes) to {}", jarFile.getOriginalFilename(), jarFile.getSize(), jarPath);
log.info("Successfully copied manifest to {}", manifestPath);
log.debug("JAR absolute path: {}", jarPath.toAbsolutePath());
log.debug("JAR file exists: {}", Files.exists(jarPath));
log.debug("JAR file is readable: {}", Files.isReadable(jarPath));
StringBuilder output = new StringBuilder(); StringBuilder output = new StringBuilder();
login(request, output); login(request, output);
pushApplication(request, tempDir, output); pushApplication(request, tempDir, jarPath, output);
logout(output); logout(output);
log.info("Deployment completed successfully for app: {}", request.getAppName()); log.info("=== Deployment completed successfully for app: {} ===", request.getAppName());
return CfDeployResponse.success(output.toString()); return CfDeployResponse.success(output.toString());
} catch (Exception e) { } catch (Exception e) {
log.error("Deployment failed for app: {}", request.getAppName(), e); log.error("=== Deployment failed for app: {} ===", request.getAppName());
log.error("Error type: {}", e.getClass().getName());
log.error("Error message: {}", e.getMessage(), e);
return CfDeployResponse.failure(e.getMessage(), e.toString()); return CfDeployResponse.failure(e.getMessage(), e.toString());
} finally { } finally {
if (tempDir != null) { if (tempDir != null) {
@@ -94,7 +107,7 @@ public class CfCliService {
log.info("Successfully logged into Cloud Foundry"); log.info("Successfully logged into Cloud Foundry");
} }
private void pushApplication(CfDeployRequest request, Path workingDir, StringBuilder output) throws Exception { private void pushApplication(CfDeployRequest request, Path workingDir, Path jarPath, StringBuilder output) throws Exception {
log.info("Pushing application: {}", request.getAppName()); log.info("Pushing application: {}", request.getAppName());
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
@@ -103,6 +116,8 @@ public class CfCliService {
command.add(request.getAppName()); command.add(request.getAppName());
command.add("-f"); command.add("-f");
command.add("manifest.yml"); command.add("manifest.yml");
command.add("-p");
command.add(jarPath.toAbsolutePath().toString());
executeCommand(command, output, false, workingDir.toFile()); executeCommand(command, output, false, workingDir.toFile());
log.info("Successfully pushed application: {}", request.getAppName()); log.info("Successfully pushed application: {}", request.getAppName());
@@ -127,6 +142,7 @@ public class CfCliService {
ProcessBuilder processBuilder = new ProcessBuilder(command); ProcessBuilder processBuilder = new ProcessBuilder(command);
if (workingDir != null) { if (workingDir != null) {
processBuilder.directory(workingDir); processBuilder.directory(workingDir);
log.debug("Working directory: {}", workingDir.getAbsolutePath());
} }
processBuilder.redirectErrorStream(true); processBuilder.redirectErrorStream(true);
@@ -137,50 +153,70 @@ public class CfCliService {
maskedCommand.set(i + 1, "********"); maskedCommand.set(i + 1, "********");
} }
} }
log.debug("Executing command: {}", String.join(" ", maskedCommand)); log.info("Executing CF CLI command: {}", String.join(" ", maskedCommand));
} else { } else {
log.debug("Executing command: {}", String.join(" ", command)); log.info("Executing CF CLI command: {}", String.join(" ", command));
} }
long startTime = System.currentTimeMillis();
Process process = processBuilder.start(); Process process = processBuilder.start();
log.debug("Process started with PID: {}", process.pid());
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line; String line;
int lineCount = 0;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
lineCount++;
output.append(line).append("\n"); output.append(line).append("\n");
log.debug("CF CLI output: {}", line); log.info("CF CLI: {}", line);
} }
log.debug("Command produced {} lines of output", lineCount);
} }
boolean finished = process.waitFor(timeout, TimeUnit.SECONDS); boolean finished = process.waitFor(timeout, TimeUnit.SECONDS);
long duration = System.currentTimeMillis() - startTime;
if (!finished) { if (!finished) {
log.error("Command execution timed out after {} seconds", timeout);
process.destroyForcibly(); process.destroyForcibly();
throw new RuntimeException("Command execution timed out after " + timeout + " seconds"); throw new RuntimeException("Command execution timed out after " + timeout + " seconds");
} }
int exitCode = process.exitValue(); int exitCode = process.exitValue();
log.info("Command completed in {}ms with exit code: {}", duration, exitCode);
if (exitCode != 0) { if (exitCode != 0) {
log.error("Command failed with exit code: {}", exitCode);
log.error("Command output:\n{}", output.toString());
throw new RuntimeException("Command failed with exit code: " + exitCode); throw new RuntimeException("Command failed with exit code: " + exitCode);
} }
} }
private String getCfCliExecutable() throws IOException { private String getCfCliExecutable() throws IOException {
if (cfCliPath != null && !cfCliPath.isEmpty()) { if (cfCliPath != null && !cfCliPath.isEmpty()) {
log.info("Using custom CF CLI path: {}", cfCliPath);
return cfCliPath; return cfCliPath;
} }
String os = getOperatingSystem(); String os = getOperatingSystem();
String executable = os.equals("windows") ? "cf.exe" : "cf"; String executable = os.equals("windows") ? "cf.exe" : "cf";
log.info("Detected operating system: {}", os);
log.debug("CF CLI executable name: {}", executable);
String resourcePath = String.format("/cf-cli/%s/%s", os, executable); String resourcePath = String.format("/cf-cli/%s/%s", os, executable);
File tempFile = File.createTempFile("cf-cli-", os.equals("windows") ? ".exe" : ""); File tempFile = File.createTempFile("cf-cli-", os.equals("windows") ? ".exe" : "");
tempFile.deleteOnExit(); tempFile.deleteOnExit();
log.debug("Extracting CF CLI from resource path: {}", resourcePath);
try (var inputStream = getClass().getResourceAsStream(resourcePath)) { try (var inputStream = getClass().getResourceAsStream(resourcePath)) {
if (inputStream == null) { if (inputStream == null) {
throw new IOException("CF CLI binary not found for OS: " + os); log.error("CF CLI binary not found in resources for OS: {}. Expected path: {}", os, resourcePath);
throw new IOException("CF CLI binary not found for OS: " + os + " at path: " + resourcePath);
} }
Files.copy(inputStream, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING); Files.copy(inputStream, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
log.debug("CF CLI extracted to temporary file: {}", tempFile.getAbsolutePath());
} }
if (!os.equals("windows")) { if (!os.equals("windows")) {
@@ -189,14 +225,16 @@ public class CfCliService {
perms.add(PosixFilePermission.OWNER_WRITE); perms.add(PosixFilePermission.OWNER_WRITE);
perms.add(PosixFilePermission.OWNER_EXECUTE); perms.add(PosixFilePermission.OWNER_EXECUTE);
Files.setPosixFilePermissions(tempFile.toPath(), perms); Files.setPosixFilePermissions(tempFile.toPath(), perms);
log.debug("Set executable permissions on CF CLI binary");
} }
log.debug("Using CF CLI executable: {}", tempFile.getAbsolutePath()); log.info("Using CF CLI executable: {}", tempFile.getAbsolutePath());
return tempFile.getAbsolutePath(); return tempFile.getAbsolutePath();
} }
private String getOperatingSystem() { private String getOperatingSystem() {
String osName = System.getProperty("os.name").toLowerCase(); String osName = System.getProperty("os.name").toLowerCase();
log.debug("System OS name property: {}", osName);
if (osName.contains("win")) { if (osName.contains("win")) {
return "windows"; return "windows";
} else if (osName.contains("mac")) { } else if (osName.contains("mac")) {
@@ -222,4 +260,97 @@ public class CfCliService {
log.warn("Failed to clean up temporary directory: {}", tempDir, e); log.warn("Failed to clean up temporary directory: {}", tempDir, e);
} }
} }
public String listApps(CfDeployRequest request) {
try {
log.info("Listing applications for org: {}, space: {}", request.getOrganization(), request.getSpace());
StringBuilder output = new StringBuilder();
login(request, output);
List<String> command = new ArrayList<>();
command.add(getCfCliExecutable());
command.add("apps");
executeCommand(command, output, false);
logout(output);
log.info("Successfully retrieved apps list");
return output.toString();
} catch (Exception e) {
log.error("Failed to list apps", e);
throw new RuntimeException("Failed to list apps: " + e.getMessage(), e);
}
}
public String listRoutes(CfDeployRequest request) {
try {
log.info("Listing routes for org: {}, space: {}", request.getOrganization(), request.getSpace());
StringBuilder output = new StringBuilder();
login(request, output);
List<String> command = new ArrayList<>();
command.add(getCfCliExecutable());
command.add("routes");
executeCommand(command, output, false);
logout(output);
log.info("Successfully retrieved routes list");
return output.toString();
} catch (Exception e) {
log.error("Failed to list routes", e);
throw new RuntimeException("Failed to list routes: " + e.getMessage(), e);
}
}
public String getAppDetails(CfDeployRequest request, String appName) {
try {
log.info("Getting details for app: {} in org: {}, space: {}", appName, request.getOrganization(), request.getSpace());
StringBuilder output = new StringBuilder();
login(request, output);
List<String> command = new ArrayList<>();
command.add(getCfCliExecutable());
command.add("app");
command.add(appName);
executeCommand(command, output, false);
logout(output);
log.info("Successfully retrieved app details for: {}", appName);
return output.toString();
} catch (Exception e) {
log.error("Failed to get app details for: {}", appName, e);
throw new RuntimeException("Failed to get app details: " + e.getMessage(), e);
}
}
public String getAppLogs(CfDeployRequest request, String appName, boolean recent) {
try {
log.info("Getting {} logs for app: {}", recent ? "recent" : "tail", appName);
StringBuilder output = new StringBuilder();
login(request, output);
List<String> command = new ArrayList<>();
command.add(getCfCliExecutable());
command.add("logs");
command.add(appName);
if (recent) {
command.add("--recent");
}
executeCommand(command, output, false);
logout(output);
log.info("Successfully retrieved logs for: {}", appName);
return output.toString();
} catch (Exception e) {
log.error("Failed to get logs for: {}", appName, e);
throw new RuntimeException("Failed to get app logs: " + e.getMessage(), e);
}
}
} }