diff --git a/.classpath b/.classpath new file mode 100644 index 0000000000000000000000000000000000000000..a6b9e8a385f1357cf14bb0d256cdef2285f17d65 --- /dev/null +++ b/.classpath @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..c5af20a3f8344ba7311aec0d7555a4f5d4117e4f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +# Build artifacts +target/ +*.jar +*.class + +# IDE files +.idea/ +*.iml +.vscode/ +*.swp +*.swo + +# Git +.git/ +.gitignore + +# Local configuration +*.log +tmp/ +*.tmp + +# OS files +.DS_Store +Thumbs.db + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml + +# Documentation (not needed in image) +*.md +doc/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c3317e56bc68664fb5404a1d76950381abaae63c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/.apt_generated/ +/.apt_generated_tests/ +/.factorypath diff --git a/.project b/.project new file mode 100644 index 0000000000000000000000000000000000000000..0fec0d0c6677c215cf30f636901a430504b18ad5 --- /dev/null +++ b/.project @@ -0,0 +1,23 @@ + + + org.etsi.osl.controllers.giter + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature + + diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000000000000000000000000000000000000..29abf999564110a0d6aca109f55f439c72b7031c --- /dev/null +++ b/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,6 @@ +eclipse.preferences.version=1 +encoding//src/main/java=UTF-8 +encoding//src/main/resources=UTF-8 +encoding//src/test/java=UTF-8 +encoding//src/test/resources=UTF-8 +encoding/=UTF-8 diff --git a/.settings/org.eclipse.jdt.apt.core.prefs b/.settings/org.eclipse.jdt.apt.core.prefs new file mode 100644 index 0000000000000000000000000000000000000000..fa6bcfb3fdb3a5ff5ccf658c79e656a02d8dbf61 --- /dev/null +++ b/.settings/org.eclipse.jdt.apt.core.prefs @@ -0,0 +1,5 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.apt.aptEnabled=true +org.eclipse.jdt.apt.genSrcDir=.apt_generated +org.eclipse.jdt.apt.genTestSrcDir=.apt_generated_tests +org.eclipse.jdt.apt.reconcileEnabled=true diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000000000000000000000000000000000000..d9cfd0599953a44fe774453d003a50ed89e46679 --- /dev/null +++ b/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,17 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.methodParameters=generate +org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 +org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve +org.eclipse.jdt.core.compiler.compliance=17 +org.eclipse.jdt.core.compiler.debug.lineNumber=generate +org.eclipse.jdt.core.compiler.debug.localVariable=generate +org.eclipse.jdt.core.compiler.debug.sourceFile=generate +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning +org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning +org.eclipse.jdt.core.compiler.processAnnotations=enabled +org.eclipse.jdt.core.compiler.release=disabled +org.eclipse.jdt.core.compiler.source=17 diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 0000000000000000000000000000000000000000..f897a7f1cb2389f85fe6381425d29f0a9866fb65 --- /dev/null +++ b/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..3879a291fe1716c9b95dd755a31fa41d6f8806dc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# Multi-stage build for org.etsi.osl.controllers.giter + +# Stage 1: Build +FROM maven:3.9-eclipse-temurin-17 AS builder + +WORKDIR /app + +# Copy pom.xml and download dependencies first (better caching) +COPY pom.xml . +RUN mvn dependency:go-offline -B || true + +# Copy source code +COPY src ./src + +# Build the application +RUN mvn clean package -DskipTests -B + +# Stage 2: Runtime +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +# Install git (required for JGit operations) +RUN apk add --no-cache git + +# Create non-root user +RUN addgroup -S giter && adduser -S giter -G giter + +# Create directories for git repository and persistence +RUN mkdir -p /app/git-repo /app/data && \ + chown -R giter:giter /app + +# Copy the built JAR from builder stage +COPY --from=builder /app/target/org.etsi.osl.controllers.giter-*-exec.jar /app/app.jar + +# Switch to non-root user +USER giter + +# Environment variables with defaults +ENV JAVA_OPTS="-Xms256m -Xmx512m" \ + SPRING_PROFILES_ACTIVE=default \ + PRODUCER_GIT_LOCAL_PATH=/app/git-repo \ + PRODUCER_GIT_REPO_URL="" \ + PRODUCER_GIT_BRANCH=main \ + GIT_USERNAME="" \ + GIT_PASSWORD="" \ + GIT_API_KEY="" \ + PRODUCER_STATUS_WATCHER_POLL_INTERVAL=10000 \ + PRODUCER_STATUS_WATCHER_TIMEOUT=30000 + +# Run the application +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..261eeb9e9f8b2b4b0d119366dda99c6fd7d35c64 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License 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. diff --git a/README.md b/README.md index ddb39aeb53e12eb99c1b739188f0f4e2129a849b..665969656d9de90b4e915825f448ed58926d3102 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,416 @@ -# org.etsi.osl.controllers.giter +# Giter Controller Service +A Spring Boot microservice that bridges TMF639 Resource management with Kubernetes-style Custom Resources (CRs) stored in Git. This service implements a GitOps-style declarative exchange model for resource lifecycle management. +You can read more about this approach here: [` GITER: A Git-Based Declarative Exchange Model Using Kubernetes-Style Custom Resources`](https://arxiv.org/abs/2511.04182) -## Getting started -To make it easy for you to get started with GitLab, here's a list of recommended next steps. +## Overview -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! +The Giter Controller acts as a "Producer" service that: -## Add your files +1. **Listens** to JMS queues for TMF639 Resource CREATE/UPDATE/DELETE operations +2. **Translates** TMF639 Resources into Kubernetes Custom Resources (CRs) +3. **Validates** CRs against a CRD schema +4. **Commits** and pushes CRs to a Git repository +5. **Monitors** CR status changes and updates TMF repository accordingly +6. **Registers** CRDs as TMF634 ResourceSpecifications via message bus -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: +## Architecture ``` -cd existing_repo -git remote add origin https://labs.etsi.org/rep/osl/code/addons/org.etsi.osl.controllers.giter.git -git branch -M main -git push -uf origin main +┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐ ┌─ ─ ─ ─ ─ ─ ─┐ +│ TMF Catalog │────▶│ Giter Service │────▶│ Git Repo │────▶│ Consumer │ +│ (ActiveMQ) │◀────│ (Producer) │◀────│ (GitOps) │◀────│ (external) │ +└─────────────────┘ └──────────────────┘ └─────────────┘ └ ─ ─ ─ ─ ─ ─ ┘ + │ + ▼ + ┌───────────────┐ + │ Status │ + │ Reconciliation│ + └───────────────┘ ``` -## Integrate with your tools +### GitOps Model -- [ ] [Set up project integrations](https://labs.etsi.org/rep/osl/code/addons/org.etsi.osl.controllers.giter/-/settings/integrations) +- **Producer** (this service) writes only the `spec` fields of Custom Resources +- **Consumer** (external service) writes only the `status` fields +- Git repository serves as the single source of truth +- Status changes are detected through polling and reconciled to TMF repository -## Collaborate with your team +### Bootstrap Process -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) +The service follows a specific bootstrap sequence on startup. See the [Bootstrap Sequence Diagram](doc/bootstrap-sequence.puml) for details. -## Test and Deploy +``` +1. Load CRD Schema → Validate group, version, kind +2. Log Configuration → Display Git and CRD settings +3. Initialize Git Repo → Clone or open repository +4. Register CRD as Spec → Create TMF634 ResourceSpecification +5. Initialize Routes → Configure JMS message handlers +``` + +## Features + +- **Schema Validation**: Validates CRs against CRD JSON schema before committing +- **Status Reconciliation**: Monitors CR status changes and updates TMF resources +- **Configurable Status Mapping**: Maps CR status values to TMF ResourceStatusType and OperationalStateType +- **Terminal State Detection**: Stops reconciliation when resources reach terminal states (SUSPENDED/DISABLE) +- **Archival**: Moves deleted CRs to archive folder instead of permanent deletion +- **Persistence**: Reconciliation tasks survive service restarts +- **Git Authentication**: Supports username/password or API token authentication + +## Prerequisites + +- Java 17+ +- Maven 3.6+ +- ActiveMQ Artemis (for JMS messaging) +- Git repository (GitHub, GitLab, Bitbucket, etc.) +- TMF Catalog service (for Resource/ResourceSpec operations) + +## Quick Start + +### 1. Clone and Build + +```bash +git clone +cd org.etsi.osl.controllers.giter +mvn clean package +``` + +### 2. Configure Environment + +Copy the example environment file: + +```bash +cd docker +cp .env.example .env +``` + +Edit `.env` with your configuration: + +```bash +# Git Repository +PRODUCER_GIT_REPO_URL=https://github.com/your-org/your-gitops-repo.git +PRODUCER_GIT_BRANCH=main +GIT_API_KEY=ghp_your_github_personal_access_token + +# ActiveMQ (defaults work for docker-compose) +SPRING_ACTIVEMQ_BROKER_URL=tcp://activemq:61616 +SPRING_ACTIVEMQ_USER=artemis +SPRING_ACTIVEMQ_PASSWORD=artemis +``` + +### 3. Prepare CRD Schema + +Place your CRD schema file in the docker folder: + +```bash +# Copy your CRD schema to docker folder +cp /path/to/your-crd.yaml docker/my_crd_schema.yaml +``` + +### 4. Run with Docker Compose + +```bash +cd docker +docker-compose up -d +``` + +This starts: +- Giter Controller service (with your .env configuration) + +The docker-compose.yml will: +- Load environment variables from `.env` file +- Use default values for any variables not specified +- Mount `my_crd_schema.yaml` as the CRD schema +- Persist git repository in `giter-git-repo` volume +- Persist reconciliation tasks in `giter-data` volume + +**Important**: The `.env` file must be in the same directory as `docker-compose.yml` (the `docker/` folder). + +### 5. Run Standalone (Alternative) + +```bash +mvn spring-boot:run +``` + +## Configuration + +### Application Properties + +Key configuration in `application.yml`: + +```yaml +producer: + git: + repository-url: ${PRODUCER_GIT_REPO_URL:} + local-path: ${PRODUCER_GIT_LOCAL_PATH:./tmp/git-repo} + branch: ${PRODUCER_GIT_BRANCH:main} + credentials: + api-key: ${GIT_API_KEY:} + + crd: + schema-file: ${PRODUCER_CRD_SCHEMA_FILE:classpath:crd-schema.yaml} + status-field-path: ${PRODUCER_CRD_STATUS_FIELD_PATH:status.state} + + status-watcher: + poll-interval-ms: ${PRODUCER_STATUS_WATCHER_POLL_INTERVAL:10000} + timeout-ms: ${PRODUCER_STATUS_WATCHER_TIMEOUT:30000} +``` + +### Status Mappings + +Configure how CR status values map to TMF types: + +```yaml +status-watcher: + status-mappings: + - cr-status: Running + resource-status-type: AVAILABLE + operational-state-type: ENABLE + - cr-status: Failed + resource-status-type: SUSPENDED + operational-state-type: DISABLE # Terminal state +``` + +Available ResourceStatusType values: +- `STANDBY`, `SUSPEND`, `AVAILABLE`, `RESERVED`, `UNKNOWN` + +Available OperationalStateType values: +- `ENABLE`, `DISABLE` + +**Terminal State**: When ResourceStatusType is `SUSPENDED` and OperationalStateType is `DISABLE`, reconciliation stops automatically. + +### Environment Variables + +For Docker/Kubernetes deployment, use array-based environment variables: + +```bash +# Status mapping index 0 +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_CR_STATUS=Running +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_RESOURCE_STATUS_TYPE=AVAILABLE +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_OPERATIONAL_STATE_TYPE=ENABLE + +# Status mapping index 1 +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_1_CR_STATUS=Failed +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_1_RESOURCE_STATUS_TYPE=SUSPENDED +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_1_OPERATIONAL_STATE_TYPE=DISABLE +``` + +## Git Repository Structure + +Custom Resources are organized in the Git repository as: -Use the built-in continuous integration in GitLab. +``` +resources/ + / + / + / + -.yaml +archive-/ + -.yaml +``` + +Example: +``` +resources/ + stable.example.com/ + v1/ + CronTab/ + my-cron-abc123.yaml +archive-CronTab/ + deleted-cron-def456.yaml +``` + +## API and Message Flow + +### Create Resource + +1. TMF catalog sends CREATE message to JMS queue +2. Service extracts `_GITER_SPEC` characteristic containing CR YAML +3. CR is validated against CRD schema +4. CR file is written to Git repository +5. Changes are committed and pushed +6. Reconciliation task starts monitoring for status changes +7. TMF resource is updated with operation notes + +See: [`doc/bootstrap-sequence.puml`](doc/bootstrap-sequence.puml) + +![`doc/bootstrap-sequence.puml`](//www.plantuml.com/plantuml/png/ZLPVRzis47_NfxXrBmwGf4sw3PYm57KLjrYND9cLzBLWg8jCX28vadBJ5VtkEugoA3bsCFY1rDtl_l-XtphFh6yR2U-qzivRPg34VrfK7BEV_0eR12EPK9bx4CemonguO_oXjhfLLQ6bjl3p-qNuZH2krp2tLd-zMGd-eD0vE1r1EdckhK8B9wz6Z8OzDWHcDJhnByBfgrTPveMN21-49t2XbRTQIheUxN8w8pLUS66Oyl2YD5QekiEz0EOWVaynrrPNBKSxA2lHhWN7Jc0WRkdbb1fseLc5bZow5tM7ZkJTwkaqI7Hq7JHykYcI0fnNp7Yq7O8-f_mbEjrQZfVCc1uJhrDOmJoJkYwaG1MZxYbJM4haKhSUtxL2LhtMZk2oxEmCviP8mPaNMa7PzOg1ixCiwnDxz2PbA1-3EonMYpKgdp7knPfv76Kp6wvREhHwgcLsa2bDubRKAvydD1YhUUYiEwo5YWwN7WsSv11g0qnLy9b9KGNZnVAyBr28OEf0fEV0RHMlCzSTxuN4W-H_NzzUoz7KyuzEgq-wGfWbImTPXLlI-qDlLSEiMp49NaFHEg-RJSdFyLue4LAoJph56MRJ7C8QhIFy8T1eLQFyPG2BHnBXFgVhKOJ338_huqDmdjr9FCP7rXWwkMuEEb2lR0DRdqHoKBsX5LQh6tPta27HWhbfeB7XSt3e5uzynLID5ULfHXcVLhVqsiPYtMiBaeMXRnscufw6ujOGnwwriJUd5KtcQlGqHzH0tVTsoOYLnd9lPeCrYqOxGQvzWzlbnI4OvbU7S6UPugizXYTnxlCWn5cXPq8-RvzmiLAndYaRhtFuCtG6as5TKa2mJAvkPoNmSn03VfPwEx2cNN84XTGAGTm3qG4VXVEkeryPL87Cf4LMVGCUS5LW3LL1u1OhLC6GIor1HhOpKBOypF1uK0YL0lmbfuXgiessom9pi9cAqY0NzuBlQO6u77Fugxr3gz2Z8_dJuBoJWkFGVOFTA935OzlF2tBXZucgpoTlejSJwKngRRhU9TOAjYGoW9q-UhqCqHGMITNiWOIAu1h0jdg0dUxjpabKHp9FtTnsf7yZSk2Tp9MzDHMTOxNlldrY3H8GQsgkpt4bp1nn9exsceWqQIU0ChJ1MZ3uU5a2FTSjvXszcD_CBwu-VBczFf-VdtzPBighssMnAAyN1V67te6DvYULqJN5AtTcO1nxSeseUtRJfjMmUaxhJ5eNOdwwj-mW3o4nNHzOtHACZ_TLXkCP_dKommLhKFPlyDEcZlJn44VAQ9I7zpkUawNqVmDCwogPV4uW6KluxYz5B3GerX3ptke6BfRbTD7ytI4CynwjokCQMIxcDukNttdVR3zUVk-Ve1yTdrhbMVxvucAnZpyqr5ENq_hggs0x-jJTY1txsMdeinAsR_y7yZYEqeOpEqYkxnLfJJKHIT7i5QbGuaHaEy3dD-waOmPKaEd_BUUGg8LkJB0saatSBEP9KwPxqSEDOe3UMn0EbFuAyU7mEgPnYHp56k7cylrlhtyVeWHjCCwBs-oD3dvZcN8osFPwj63x_qo3kEjVfAOH_kLrwrO17bwWz9XtY4kjmWSuptph83xQqQ95hXKNKiGcJvbzIzUsaTb_) + +### Update Resource + +Similar to CREATE, but updates existing CR file and continues existing reconciliation. + +See: [`doc/create-resource-sequence.puml`](doc/create-resource-sequence.puml) + +![`doc/create-resource-sequence.puml`](//www.plantuml.com/plantuml/png/bLXjRnkv4Vw-lsAg0muo6bd9zPk052UlvTIjbJYMd4DH50TwToIpuYwt95U9Q_Y_TsR9rIxbLPhwW1ENyypmcHSEoVngdJ2kBXIa1eL3k4EhQvCXhF3VDPOP9dzmZrWWL4h8Ch4xMLR2Y08UHBRR6bsNUQgLDl3JrOB_wo5ii30tHdzTFyfiLwAry4iFqkXdsfHeuDtRdg0Gtme2YorQ-H-4Nzw-JH8ddKBu81s1KrqwevMYuUGLzSKUIvSaPCd9J5QYT31QBwyW5KuelHs1i43VCM2UERd7vUT6klmS2xlbxdJjyAAMAaVJG9i96yDR9dTOwHMQlSommOPn35x9ebBOgYn5LGNBVdW4pYWPuejGCXTEUzpXAuPIeEQvg5omHfz7ffnmjVrDE39f-Yp3L8mEiKkLfDWsi32JvFJx82msAmAxR2MHUM9Gyvaaxz_3qcUJ9ZAidDGbl7-V99oLoPyft3EAJPb3UhUOhnSGKf-KsY4OkNrq2S3d6ckS1SZqEHCEjxJCo_HvZyQInHS2_GL5Jbyp6av0c-qvEYlFjLNd9WJuEdyji-mzYs2i6t92dINCXmaaoV8paGo9dd7fsRh00qa_puWGn1baN927b8uVFQatq7Bm1NkM2Auv6hMQJQGMtvmXGLVErvTDh08cMWWzzkxWMxlMMIzsC_Y0bC4JqHZ1nkW2WfxFbQZWBg8AJXzcxgkSPs9AeLXdHowkTQmrFXc1KEp-1oPz9sCJIOx7IipvrkYCkqlWgmru-CdGLLx1up-PmCzNrnleujED2JAODVZfXiDyeUeuWMbjdIuEzFzN4ZsyHLzHKj97mG8qqZgPsV5Pl25kjDx1XZmT_VxXUhsu-trrkqX7a4LQXCjqwOGiPRcbpGD_dozlm3f3dxqI5yeTcTbmQoRj8SQtmbZqbfocwYffiUnlzGEQ4XtQu0EP1NAwxKy8yotHi0vy4-DuHFQxHZQ3VT0PPr5KpaYX3Ny7Jw0RnwKJfgKkoKAjE6ev3XXWiyhruB2wJzF5QiMInedVZFJR86vomOd83KBCu2lZuvIECzClBFQbyuQrU89_sGWlCGkhRgJ2HXQbd_zkXNlizfoTFlE9N7KjaKPqfERqKue2NoRFtV8lvq-YKC7EuflWGwXlob9NLtYENdAUwMAwVpTDZIxNuc5QF4qo6ax4G_RkbpzrfjgAwbo9--8LkSAa6tcEaVTjg5DT5AH9rLzkqPwCSDR0ngD_-3txJ_2kWdTrzF-6UaiV8i_Xvvzv6AmFkX5ar_dtdBYjxQFlceLsUDA9Yc3Z7wN7u656gphZPdQIqD0cdfEprkk5b0rLsEYeYuMpBGTPilbH2_EL-Dnb-EKWIaEuM1o6GTZrdn2rbHE6jrUcoqmgwRVYGEp25MK6bXLY_BZVktdBTPpYGBTMlFNm4LaFXqbiVotiRZXM4t0aOqVlxwylkybYqqQFPHsz0q9KyelVi6-0YvWvVnG57W2T2-qKU-uuY-dw-ikYFsiSvdCt8tfVnsST2LcWhjr107zifvOspiN9Q731EEt3Xnm52uMeJi8hziYw1aZhaMOkNqMEIbViaONdLE5efYQAzSInzlc_h2vZNb7rdyXFZaEPFhqPugM7ziGCHl5QRRmfmEkG-cUct5GuAQEos3zLhkRNDulBxxBOTsWqHfiJFC8PsaDlX5J811erImIQf2O9auWFTpRrtGD-jPCLNnR29Odl2l7w7_MhwutJRTDiLirny7ORRcLqyJzvCGeFYndK3RQ5NJpHzVQ7xcN1G7nR2WlBCbDrZk65yAb2qsNRmZWqn2a7ipPab557EUS4avsDReHVZl95HkG6HFdKUq44GkGSE-xVEi4xs4i1Vrsk_5EevtqWdBTXwgmCNexxyGx0Nb1xpvTO8KxGuIqJtcJpR4VDGc6-xPuxdCGu87mHhM1ySVW7WJFEg8--tgDfTzyR_ne467fvGgMLiY0SaEcDtDQcEMCSIOKYdN6E6r4hDuDtR-cliBwZeCFCWMqCGkPl3BrhhM9c2-BmrDZl6mpBzqvYfQXV3XwxIhNp_GPt8nnQTrWr6Mw3ToZuNHikSOEGz16ptR4JuKgwwi_cKG_yyQqIR8UDCdnuBKMpumCBfvjAFHmVhMLFP_rKuIlHOIy9nIWCeFXayKwjqHIISFuGW_7gdjhInyl5vVJoUZM_k5buZiFHMWgpytim_RIylLci5vVVGQ-ShfhSSmyBR7nZz6tgL_f_NQZalm00) + +### Delete Resource + +1. Stops active reconciliation for the resource +2. Moves CR file to archive folder +3. Commits and pushes the deletion +4. Updates TMF resource with deletion notes + +## Development + +### Build Commands + +```bash +# Compile with annotation processing (Lombok + MapStruct) +mvn clean compile + +# Run tests +mvn test + +# Package application +mvn clean package + +# Update license headers +mvn license:update-file-header +``` + +### Project Structure + +``` +src/main/java/org/etsi/osl/controllers/giter/ +├── GiterSpringBoot.java # Main application +├── adapter/git/ # Git operations (JGit) +├── api/ # Camel routes, catalog client +├── bootstrap/ # Startup initialization +├── config/ # Configuration properties +├── exception/ # Custom exceptions +├── model/ # Data models +└── service/ # Business logic + ├── ResourceRepoService.java # CREATE/UPDATE/DELETE handler + ├── StatusWatcherService.java # Reconciliation engine + ├── ResourceMapper.java # TMF ↔ CR conversion + └── SchemaValidator.java # CRD validation +``` + +### Testing + +Run the test suite: + +```bash +mvn test +``` + +Tests cover: +- ReconciliationTask model and serialization +- StatusWatcherService reconciliation lifecycle +- ResourceRepoService CRUD operations +- JGitAdapter file operations +- Schema validation + +## Monitoring + +### Active Reconciliation Tasks + +The service persists reconciliation tasks to: +``` +/.giter/reconciliation-tasks.json +``` + +Tasks are automatically resumed after service restart. + +### Logging -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) +Configure logging levels in `application.yml`: -*** +```yaml +logging: + level: + org.etsi.osl.controllers.giter: DEBUG + org.apache.camel: INFO +``` + +## Docker Deployment -# Editing this README +### Build Docker Image -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. +```bash +docker build -t giter-controller . +``` -## Suggestions for a good README +### Run with Docker Compose -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. +```bash +cd docker +cp .env.example .env +# Edit .env with your configuration +# Place your CRD schema as my_crd_schema.yaml +docker-compose up -d +``` -## Name -Choose a self-explaining name for your project. +### Docker Compose Structure -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. +The `docker/` folder contains: +- `docker-compose.yml` - Service definitions +- `.env.example` - Template for environment variables +- `.env` - Your actual configuration (create from .env.example) +- `my_crd_schema.yaml` - Your CRD schema file (you must provide this) -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. +### Environment Configuration + +The docker-compose.yml uses `env_file` to load variables from `.env`: + +```yaml +env_file: + - .env +environment: + PRODUCER_GIT_REPO_URL: "${PRODUCER_GIT_REPO_URL}" + GIT_API_KEY: "${GIT_API_KEY}" + # ... uses defaults if not set in .env +``` -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. +See `docker/.env.example` for all available environment variables. -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. +### Volumes -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. +- `giter-git-repo` - Persists the cloned Git repository +- `giter-data` - Persists reconciliation tasks across restarts -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. +## Security -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. +- Git credentials should be provided via environment variables, not in configuration files +- Use API tokens instead of passwords when possible +- ActiveMQ credentials should be externalized +- OAuth2/Keycloak integration available for API security -## Contributing -State if you are open to contributions and what your requirements are for accepting them. +## Troubleshooting -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. +### Common Issues -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. +1. **Git authentication failed** + - Verify GIT_API_KEY or GIT_USERNAME/GIT_PASSWORD + - Ensure token has repository write permissions -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. +2. **Schema validation errors** + - Check CRD schema file path and format + - Verify CR YAML matches expected schema + +3. **Reconciliation not starting** + - Ensure GitAdapter is initialized (check logs) + - Verify `_GITER_SPEC` characteristic contains valid CR YAML + +4. **Status changes not detected** + - Check poll interval configuration + - Verify status-field-path matches CR structure + - Ensure Git repository is accessible + +### Debug Logging + +Enable debug logging: + +```yaml +logging: + level: + org.etsi.osl.controllers.giter: DEBUG +``` ## License -For open source projects, say how it is licensed. -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +Apache License 2.0 diff --git a/doc/bootstrap-sequence.md b/doc/bootstrap-sequence.md new file mode 100644 index 0000000000000000000000000000000000000000..bf2e30e60b6867b45661aee075fd4a3e55e9f396 --- /dev/null +++ b/doc/bootstrap-sequence.md @@ -0,0 +1,131 @@ +@startuml Bootstrap Sequence +!theme plain +skinparam backgroundColor #FEFEFE +skinparam sequenceArrowThickness 2 +skinparam roundcorner 10 +skinparam maxmessagesize 200 + +title Giter Controller - Bootstrap Sequence + +participant "Spring Boot" as Spring +participant "ProducerBootstrapService" as Bootstrap +participant "SchemaLoaderConfig" as Schema +participant "ProducerProperties" as Props +participant "GitAdapter" as Git +participant "ResourceMapper" as Mapper +participant "CatalogClient" as Catalog +participant "ControllerRouteBuilder" as Routes + +== Application Startup == + +Spring -> Bootstrap: ApplicationStartedEvent +activate Bootstrap + +Bootstrap -> Bootstrap: onApplicationStarted() +note right: Bootstrap process begins + +== Step 1: Load and Validate CRD Schema == + +Bootstrap -> Schema: crdSchema(schemaFile) +activate Schema +Schema --> Bootstrap: JsonNode (CRD Schema) +deactivate Schema + +Bootstrap -> Bootstrap: unmarshal to CustomResourceDefinition +Bootstrap -> Bootstrap: Validate group, version, kind +note right + Extract: + - group (e.g., stable.example.com) + - version (e.g., v1) + - kind (e.g., CronTab) +end note + +Bootstrap -> Props: setExchangedCRD(crd) +note right: Store CRD for later use + +== Step 2: Log Configuration == + +Bootstrap -> Props: getGit() +Props --> Bootstrap: GitProperties +Bootstrap -> Bootstrap: Log repository URL, path, branch +Bootstrap -> Props: getExchangedCRD() +Props --> Bootstrap: CRD details +Bootstrap -> Bootstrap: Log CRD group, kind + +== Step 3: Initialize Git Repository == + +Bootstrap -> Git: initialize() +activate Git +note right + - Clone if not exists + - Open if already cloned + - Configure credentials +end note +Git --> Bootstrap: Success +deactivate Git + +== Step 4: Register CRD as ResourceSpecification == + +Bootstrap -> Mapper: KubernetesCRD2OpensliceCRD(crd) +activate Mapper +Mapper --> Bootstrap: List +deactivate Mapper + +loop for each KubernetesCRDV1 + Bootstrap -> Mapper: toRSpecCreate(kubeCrd) + activate Mapper + Mapper --> Bootstrap: ResourceSpecificationCreate + deactivate Mapper + + Bootstrap -> Catalog: createOrUpdateResourceSpecByNameCategoryVersion(spec) + activate Catalog + note right + Send via JMS queue: + CATALOG_UPDADD_RESOURCESPEC + end note + Catalog --> Bootstrap: LogicalResourceSpecification + deactivate Catalog + + Bootstrap -> Props: setRegisteredLogicalResourceSpecification(lrs) + note right: Store registered spec for route configuration +end + +== Step 5: Initialize Camel Routes == + +Bootstrap -> Routes: initializeRoutes() +activate Routes + +Routes -> Props: getRegisteredLogicalResourceSpecification() +Props --> Routes: LogicalResourceSpecification + +Routes -> Routes: Build queue names from LRS +note right + Queue pattern: + - CREATE/{category}/{version} + - UPDATE/{category}/{version} + - DELETE/{category}/{version} +end note + +Routes -> Routes: Configure Camel routes +note right + Routes: + - CREATE -> ResourceRepoService.createResource() + - UPDATE -> ResourceRepoService.updateResource() + - DELETE -> ResourceRepoService.deleteResource() +end note + +Routes --> Bootstrap: Success +deactivate Routes + +Bootstrap --> Spring: Bootstrap Complete +deactivate Bootstrap + +note over Spring, Routes + Service is now ready to: + - Receive TMF639 Resource operations via JMS + - Translate to Kubernetes Custom Resources + - Commit/Push to Git repository + - Monitor status changes via reconciliation +end note + +@enduml diff --git a/doc/bootstrap-sequence.puml b/doc/bootstrap-sequence.puml new file mode 100644 index 0000000000000000000000000000000000000000..bf2e30e60b6867b45661aee075fd4a3e55e9f396 --- /dev/null +++ b/doc/bootstrap-sequence.puml @@ -0,0 +1,131 @@ +@startuml Bootstrap Sequence +!theme plain +skinparam backgroundColor #FEFEFE +skinparam sequenceArrowThickness 2 +skinparam roundcorner 10 +skinparam maxmessagesize 200 + +title Giter Controller - Bootstrap Sequence + +participant "Spring Boot" as Spring +participant "ProducerBootstrapService" as Bootstrap +participant "SchemaLoaderConfig" as Schema +participant "ProducerProperties" as Props +participant "GitAdapter" as Git +participant "ResourceMapper" as Mapper +participant "CatalogClient" as Catalog +participant "ControllerRouteBuilder" as Routes + +== Application Startup == + +Spring -> Bootstrap: ApplicationStartedEvent +activate Bootstrap + +Bootstrap -> Bootstrap: onApplicationStarted() +note right: Bootstrap process begins + +== Step 1: Load and Validate CRD Schema == + +Bootstrap -> Schema: crdSchema(schemaFile) +activate Schema +Schema --> Bootstrap: JsonNode (CRD Schema) +deactivate Schema + +Bootstrap -> Bootstrap: unmarshal to CustomResourceDefinition +Bootstrap -> Bootstrap: Validate group, version, kind +note right + Extract: + - group (e.g., stable.example.com) + - version (e.g., v1) + - kind (e.g., CronTab) +end note + +Bootstrap -> Props: setExchangedCRD(crd) +note right: Store CRD for later use + +== Step 2: Log Configuration == + +Bootstrap -> Props: getGit() +Props --> Bootstrap: GitProperties +Bootstrap -> Bootstrap: Log repository URL, path, branch +Bootstrap -> Props: getExchangedCRD() +Props --> Bootstrap: CRD details +Bootstrap -> Bootstrap: Log CRD group, kind + +== Step 3: Initialize Git Repository == + +Bootstrap -> Git: initialize() +activate Git +note right + - Clone if not exists + - Open if already cloned + - Configure credentials +end note +Git --> Bootstrap: Success +deactivate Git + +== Step 4: Register CRD as ResourceSpecification == + +Bootstrap -> Mapper: KubernetesCRD2OpensliceCRD(crd) +activate Mapper +Mapper --> Bootstrap: List +deactivate Mapper + +loop for each KubernetesCRDV1 + Bootstrap -> Mapper: toRSpecCreate(kubeCrd) + activate Mapper + Mapper --> Bootstrap: ResourceSpecificationCreate + deactivate Mapper + + Bootstrap -> Catalog: createOrUpdateResourceSpecByNameCategoryVersion(spec) + activate Catalog + note right + Send via JMS queue: + CATALOG_UPDADD_RESOURCESPEC + end note + Catalog --> Bootstrap: LogicalResourceSpecification + deactivate Catalog + + Bootstrap -> Props: setRegisteredLogicalResourceSpecification(lrs) + note right: Store registered spec for route configuration +end + +== Step 5: Initialize Camel Routes == + +Bootstrap -> Routes: initializeRoutes() +activate Routes + +Routes -> Props: getRegisteredLogicalResourceSpecification() +Props --> Routes: LogicalResourceSpecification + +Routes -> Routes: Build queue names from LRS +note right + Queue pattern: + - CREATE/{category}/{version} + - UPDATE/{category}/{version} + - DELETE/{category}/{version} +end note + +Routes -> Routes: Configure Camel routes +note right + Routes: + - CREATE -> ResourceRepoService.createResource() + - UPDATE -> ResourceRepoService.updateResource() + - DELETE -> ResourceRepoService.deleteResource() +end note + +Routes --> Bootstrap: Success +deactivate Routes + +Bootstrap --> Spring: Bootstrap Complete +deactivate Bootstrap + +note over Spring, Routes + Service is now ready to: + - Receive TMF639 Resource operations via JMS + - Translate to Kubernetes Custom Resources + - Commit/Push to Git repository + - Monitor status changes via reconciliation +end note + +@enduml diff --git a/doc/create-resource-sequence.puml b/doc/create-resource-sequence.puml new file mode 100644 index 0000000000000000000000000000000000000000..485a8ff38290dad68dc6a40abdb088483741fa85 --- /dev/null +++ b/doc/create-resource-sequence.puml @@ -0,0 +1,195 @@ +@startuml Create Resource Sequence +!theme plain +skinparam backgroundColor #FEFEFE +skinparam sequenceArrowThickness 2 +skinparam roundcorner 10 +skinparam maxmessagesize 200 + +title Giter Controller - Create Resource Event + +participant "TMF Catalog" as TMF +participant "ActiveMQ" as MQ +participant "ControllerRouteBuilder" as Routes +participant "ResourceRepoService" as Service +participant "SimpleResourceMapper" as Mapper +participant "SchemaValidator" as Validator +participant "GitAdapter" as Git +participant "StatusWatcherService" as Watcher +participant "CatalogClient" as Catalog +database "Git Repository" as Repo + +== Message Reception == + +TMF -> MQ: Send CREATE message +note right + Queue: CREATE/{category}/{version} + Headers: + - org.etsi.osl.resourceId + - org.etsi.osl.serviceId (optional) +end note + +MQ -> Routes: Consume message +Routes -> Service: createResource(headers, resourceCreate) +activate Service + +== Extract Resource ID == + +Service -> Service: extractResourceId(headers) +note right: Get "org.etsi.osl.resourceId" from headers + +== Map ResourceCreate to ResourceUpdate == + +Service -> Mapper: resourceCreateToResourceUpdate(resourceCreate) +activate Mapper +Mapper --> Service: ResourceUpdate +deactivate Mapper + +== Process Create or Update == + +Service -> Service: processCreateOrUpdate(CREATE, resourceId, resourceUpdate) +activate Service #LightBlue + +== Extract Custom Resource == + +Service -> Service: extractCustomResourceFromCharacteristics() +note right + Look for "_GITER_SPEC" characteristic + containing CR YAML string +end note + +alt "_GITER_SPEC" found + Service -> Service: Parse YAML to GenericKubernetesResource + + == Validate Against Schema == + + Service -> Validator: validate(customResource) + activate Validator + Validator --> Service: ValidationResult + deactivate Validator + + alt Validation SUCCESS + + == Write to Git Repository == + + Service -> Git: writeCustomResource(cr, resourceId) + activate Git + Git -> Repo: Write file + note right + Path: resources/{group}/{version}/{kind}/{name}-{resourceId}.yaml + Example: resources/stable.example.com/v1/CronTab/my-cron-abc123.yaml + end note + Git --> Service: crFilePath + deactivate Git + + == Commit Changes == + + Service -> Git: commit("[CREATE] {kind} {name}", resourceId) + activate Git + Git -> Repo: git add && git commit + Git --> Service: commitId + deactivate Git + + == Push to Remote == + + Service -> Git: push() + activate Git + Git -> Repo: git push + Git --> Service: Success + deactivate Git + + Service -> Service: Add success note + note right + "Custom Resource created in Git" + "Path: {crFilePath}" + "CommitId: {commitId}" + end note + + == Start Reconciliation == + + Service -> Watcher: startReconciliation(resourceId, cr, crFilePath) + activate Watcher + + Watcher -> Watcher: Create ReconciliationTask + note right + - taskId: UUID + - tmfResourceId: resourceId + - apiVersion, kind, crName + - crFilePath + - state: ACTIVE + - startedAt: now() + - timeoutAt: now() + timeoutMs + end note + + Watcher -> Watcher: Add to activeTasks map + Watcher -> Watcher: persistTasks() to disk + note right: Save to .giter/reconciliation-tasks.json + + Watcher --> Service: ReconciliationTask + deactivate Watcher + + Service -> Service: Add reconciliation note + note right: "Reconciliation started - TaskId: {taskId}" + + else Validation FAILED + Service -> Service: Add validation error note + note right: "Schema validation failed: {errors}" + end + +else "_GITER_SPEC" not found + Service -> Service: Skip CR processing + note right: No Custom Resource to commit +end + +deactivate Service + +== Update TMF Resource == + +Service -> Catalog: updateResourceById(resourceId, resourceUpdate) +activate Catalog +note right + Update includes: + - Operation notes (success/failure) + - Reconciliation task info + - Validation errors (if any) +end note +Catalog -> TMF: Send update via JMS +TMF --> Catalog: Updated Resource +Catalog --> Service: Resource +deactivate Catalog + +Service --> Routes: Resource +deactivate Service + +Routes --> MQ: Acknowledge message + +== Reconciliation Loop (Background) == + +note over Watcher, Repo + StatusWatcherService polls at configured interval + (default: 10000ms) to detect status changes +end note + +loop Every poll interval + Watcher -> Git: pull() + Git -> Repo: git pull + Git --> Watcher: Latest changes + + Watcher -> Watcher: Read CR file + Watcher -> Watcher: Check status changes + + alt Status changed + Watcher -> Catalog: updateResourceById(resourceId, statusUpdate) + note right + Map CR status to TMF: + - ResourceStatusType + - ResourceOperationalStateType + end note + + alt Terminal state (SUSPENDED/DISABLE) + Watcher -> Watcher: Mark task COMPLETED + Watcher -> Watcher: Stop polling + end + end +end + +@enduml diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..919b76d0813da9c280f7567f75d0e97a87b35d44 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,88 @@ +# Environment variables for Giter Controller Service +# Copy this file to .env and fill in your values + +# Git Repository Configuration +PRODUCER_GIT_REPO_URL=https://github.com/your-org/your-gitops-repoxx.git +PRODUCER_GIT_BRANCH=main + +# Git Credentials (choose one method) +# Method 1: Personal Access Token (recommended) +GIT_API_KEY=9-your-git-token-herexx + +# Method 2: Username/Password (for GitLab, Bitbucket, etc.) +# GIT_USERNAME=your-username +# GIT_PASSWORD=your-password + +# ActiveMQ Configuration +SPRING_ACTIVEMQ_BROKER_URL=tcp://host.docker.internal:61616 +SPRING_ACTIVEMQ_USER=artemis +SPRING_ACTIVEMQ_PASSWORD=artemis + +# Status Watcher Configuration +PRODUCER_STATUS_WATCHER_POLL_INTERVAL=10000 +PRODUCER_STATUS_WATCHER_TIMEOUT=30000 +# Persistence path for reconciliation tasks (leave empty to use .giter inside git repo) +PRODUCER_STATUS_WATCHER_PERSISTENCE_PATH=/app/data +PRODUCER_STATUS_WATCHER_DEFAULT_RESOURCE_STATUS_TYPE=UNKNOWN +PRODUCER_STATUS_WATCHER_DEFAULT_OPERATIONAL_STATE_TYPE=DISABLE + +# Status Mappings (CR status -> TMF ResourceStatusType / OperationalStateType) +# Array-based configuration: PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS__ +# ResourceStatusType values: STANDBY, SUSPEND, AVAILABLE, RESERVED, UNKNOWN +# OperationalStateType values: ENABLE, DISABLE +# +# Index 0: Running -> AVAILABLE/ENABLE +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_CR_STATUS=Running +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_RESOURCE_STATUS_TYPE=AVAILABLE +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_OPERATIONAL_STATE_TYPE=ENABLE +# +# Index 1: READY -> AVAILABLE/ENABLE +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_1_CR_STATUS=READY +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_1_RESOURCE_STATUS_TYPE=AVAILABLE +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_1_OPERATIONAL_STATE_TYPE=ENABLE +# +# Index 2: Active -> AVAILABLE/ENABLE +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_2_CR_STATUS=Active +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_2_RESOURCE_STATUS_TYPE=AVAILABLE +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_2_OPERATIONAL_STATE_TYPE=ENABLE +# +# Index 3: Provisioning -> RESERVED/DISABLE +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_3_CR_STATUS=Provisioning +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_3_RESOURCE_STATUS_TYPE=RESERVED +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_3_OPERATIONAL_STATE_TYPE=DISABLE +# +# Index 4: Pending -> RESERVED/DISABLE +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_4_CR_STATUS=Pending +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_4_RESOURCE_STATUS_TYPE=RESERVED +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_4_OPERATIONAL_STATE_TYPE=DISABLE +# +# Index 5: Stopped -> SUSPENDED/ENABLE (can be restarted) +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_5_CR_STATUS=Stopped +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_5_RESOURCE_STATUS_TYPE=SUSPENDED +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_5_OPERATIONAL_STATE_TYPE=ENABLE +# +# Index 6: Failed -> SUSPENDED/DISABLE (terminal state, stops reconciliation) +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_6_CR_STATUS=Failed +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_6_RESOURCE_STATUS_TYPE=SUSPENDED +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_6_OPERATIONAL_STATE_TYPE=DISABLE +# +# Index 7: Error -> SUSPENDED/DISABLE (terminal state, stops reconciliation) +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_7_CR_STATUS=Error +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_7_RESOURCE_STATUS_TYPE=SUSPENDED +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_7_OPERATIONAL_STATE_TYPE=DISABLE +# +# Index 8: Unknown -> UNKNOWN/DISABLE +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_8_CR_STATUS=Unknown +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_8_RESOURCE_STATUS_TYPE=UNKNOWN +PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_8_OPERATIONAL_STATE_TYPE=DISABLE + +# CRD Schema +PRODUCER_CRD_SCHEMA_FILE=file:/app/config/my_crd_schema.yaml +PRODUCER_CRD_STATUS_FIELD_PATH=status.state + +# Java Memory Settings +JAVA_OPTS=-Xms256m -Xmx512m + +# Logging Levels +LOGGING_LEVEL_ORG_ETSI_OSL_CONTROLLERS_GITER=INFO +LOGGING_LEVEL_ORG_APACHE_CAMEL=INFO diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..977b3f511e67932949d0dd22c1495a2e02b23f4b --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,105 @@ +version: '3.8' + +services: + + # Giter Controller Service + giter: + build: + context: ../. + dockerfile: Dockerfile + container_name: giter-service + env_file: + - .env + environment: + # Java options + JAVA_OPTS: "${JAVA_OPTS:--Xms256m -Xmx512m}" + + # Spring profile + SPRING_PROFILES_ACTIVE: "${SPRING_PROFILES_ACTIVE:-default}" + + # ActiveMQ configuration + SPRING_ACTIVEMQ_BROKER_URL: "${SPRING_ACTIVEMQ_BROKER_URL:-tcp://host.docker.internal:61616}" + SPRING_ACTIVEMQ_USER: "${SPRING_ACTIVEMQ_USER:-artemis}" + SPRING_ACTIVEMQ_PASSWORD: "${SPRING_ACTIVEMQ_PASSWORD:-artemis}" + + # Git repository configuration + PRODUCER_GIT_REPO_URL: "${PRODUCER_GIT_REPO_URL}" + PRODUCER_GIT_LOCAL_PATH: "${PRODUCER_GIT_LOCAL_PATH:-/app/gitrepo}" + PRODUCER_GIT_BRANCH: "${PRODUCER_GIT_BRANCH:-main}" + + # Git credentials (use one of the following methods) + # Method 1: Username/Password + GIT_USERNAME: "${GIT_USERNAME:-}" + GIT_PASSWORD: "${GIT_PASSWORD:-}" + + # Method 2: API Token (recommended) + GIT_API_KEY: "${GIT_API_KEY}" + + # CRD Schema configuration + PRODUCER_CRD_SCHEMA_FILE: "${PRODUCER_CRD_SCHEMA_FILE:-file:/app/config/my_crd_schema.yaml}" + PRODUCER_CRD_STATUS_FIELD_PATH: "${PRODUCER_CRD_STATUS_FIELD_PATH:-status.state}" + + # Status watcher configuration + PRODUCER_STATUS_WATCHER_POLL_INTERVAL: "${PRODUCER_STATUS_WATCHER_POLL_INTERVAL:-10000}" + PRODUCER_STATUS_WATCHER_TIMEOUT: "${PRODUCER_STATUS_WATCHER_TIMEOUT:-30000}" + PRODUCER_STATUS_WATCHER_PERSISTENCE_PATH: "${PRODUCER_STATUS_WATCHER_PERSISTENCE_PATH:-/app/data}" + PRODUCER_STATUS_WATCHER_DEFAULT_RESOURCE_STATUS_TYPE: "${PRODUCER_STATUS_WATCHER_DEFAULT_RESOURCE_STATUS_TYPE:-UNKNOWN}" + PRODUCER_STATUS_WATCHER_DEFAULT_OPERATIONAL_STATE_TYPE: "${PRODUCER_STATUS_WATCHER_DEFAULT_OPERATIONAL_STATE_TYPE:-DISABLE}" + + # Status mappings (CR status -> TMF ResourceStatusType / OperationalStateType) + # Array-based configuration: PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS__ + # Index 0: Running -> AVAILABLE/ENABLE + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_CR_STATUS: Running + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_RESOURCE_STATUS_TYPE: AVAILABLE + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_OPERATIONAL_STATE_TYPE: ENABLE + # Index 1: READY -> AVAILABLE/ENABLE + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_1_CR_STATUS: READY + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_1_RESOURCE_STATUS_TYPE: AVAILABLE + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_1_OPERATIONAL_STATE_TYPE: ENABLE + # Index 2: Active -> AVAILABLE/ENABLE + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_2_CR_STATUS: Active + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_2_RESOURCE_STATUS_TYPE: AVAILABLE + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_2_OPERATIONAL_STATE_TYPE: ENABLE + # Index 3: Provisioning -> RESERVED/DISABLE + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_3_CR_STATUS: Provisioning + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_3_RESOURCE_STATUS_TYPE: RESERVED + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_3_OPERATIONAL_STATE_TYPE: DISABLE + # Index 4: Pending -> RESERVED/DISABLE + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_4_CR_STATUS: Pending + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_4_RESOURCE_STATUS_TYPE: RESERVED + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_4_OPERATIONAL_STATE_TYPE: DISABLE + # Index 5: Stopped -> SUSPENDED/ENABLE + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_5_CR_STATUS: Stopped + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_5_RESOURCE_STATUS_TYPE: SUSPENDED + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_5_OPERATIONAL_STATE_TYPE: ENABLE + # Index 6: Failed -> SUSPENDED/DISABLE (terminal state) + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_6_CR_STATUS: Failed + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_6_RESOURCE_STATUS_TYPE: SUSPENDED + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_6_OPERATIONAL_STATE_TYPE: DISABLE + # Index 7: Error -> SUSPENDED/DISABLE (terminal state) + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_7_CR_STATUS: Error + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_7_RESOURCE_STATUS_TYPE: SUSPENDED + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_7_OPERATIONAL_STATE_TYPE: DISABLE + # Index 8: Unknown -> UNKNOWN/DISABLE + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_8_CR_STATUS: Unknown + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_8_RESOURCE_STATUS_TYPE: UNKNOWN + PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_8_OPERATIONAL_STATE_TYPE: DISABLE + + # Logging + LOGGING_LEVEL_ORG_ETSI_OSL_CONTROLLERS_GITER: INFO + LOGGING_LEVEL_ORG_APACHE_CAMEL: INFO + + volumes: + # Persist git repository + - giter-git-repo:/app/git-repo + # Persist reconciliation tasks + - giter-data:/app/data + # Mount custom CRD schema file + - ./my_crd_schema.yaml:/app/config/my_crd_schema.yaml:ro + restart: unless-stopped + +volumes: + giter-git-repo: + driver: local + giter-data: + driver: local diff --git a/docker/my_crd_schema.yaml b/docker/my_crd_schema.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f9798924d16341ee4cccb63a3456282142461c6b --- /dev/null +++ b/docker/my_crd_schema.yaml @@ -0,0 +1,84 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: virtualmachines.compute.example.com +spec: + group: compute.example.com + scope: Namespaced + names: + plural: virtualmachines + singular: virtualmachine + kind: VirtualMachineGPU + shortNames: + - vmgpu + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: + - cpu + - memory + - image + properties: + cpu: + type: integer + minimum: 1 + description: Number of virtual CPUs + memory: + type: string + pattern: "^[0-9]+(Mi|Gi)$" + description: RAM size (e.g., 512Mi, 4Gi) + image: + type: string + description: VM disk image reference (e.g., ubuntu-22.04) + disksize: + type: string + description: Disk size in GB + gpus: + type: integer + minimum: 1 + description: Number of GPUs + powerState: + type: string + enum: ["Running", "Stopped"] + description: Desired power state of the VM + status: + type: object + properties: + state: + type: string + description: Current operational state of the VM + enum: ["Provisioning", "Running", "Stopped", "Failed", "Unknown", "Error"] + hostIP: + type: string + description: Host IP where the VM is running + vmIP: + type: string + description: Assigned IP address of the VM + message: + type: string + description: Human-readable status message + subresources: + status: {} + additionalPrinterColumns: + - name: CPU + type: integer + jsonPath: .spec.cpu + - name: Memory + type: string + jsonPath: .spec.memory + - name: Image + type: string + jsonPath: .spec.image + - name: Power + type: string + jsonPath: .spec.powerState + - name: State + type: string + jsonPath: .status.state diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..8b62b4c8d45d7aaa203fb00abb7a06fdd1d35aa4 --- /dev/null +++ b/pom.xml @@ -0,0 +1,416 @@ + + 4.0.0 + org.etsi.osl + org.etsi.osl.controllers.giter + 0.0.1-SNAPSHOT + org.etsi.osl.controllers.giter + org.etsi.osl.controllers.giter + + + + UTF-8 + UTF-8 + 3.2.2 + 1.18.28 + 2.1.0 + 1.5.3.Final + 17 + 4.0.0-RC2 + 2.15.3 + 2.0.0 + 5.13.3.202401111512-r + 1.0.76 + apache_v2 + 1.7.0 + 1.7.0 + 22.0.1 + 1.1.0-SNAPSHOT + 1.1.2-SNAPSHOT + 7.3.1 + + + + + gitlab-maven + https://labs.etsi.org/rep/api/v4/groups/260/-/packages/maven + + + + + gitlab-maven + ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/maven + + + gitlab-maven + ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/maven + + + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot-version} + pom + import + + + + org.apache.camel.springboot + camel-spring-boot-dependencies + ${camel.version} + pom + import + + + + com.google.guava + guava + 32.0.0-jre + + + org.keycloak.bom + keycloak-adapter-bom + ${keycloak.version} + pom + import + + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + com.jayway.jsonpath + json-path + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.security + spring-security-oauth2-client + + + org.springframework.security + spring-security-core + + + org.springframework.security + spring-security-web + + + org.springframework.security + spring-security-config + + + + + org.projectlombok + lombok + provided + ${lombok-version} + + + org.openapitools + jackson-databind-nullable + 0.2.6 + + + + org.keycloak + keycloak-spring-boot-starter + + + org.keycloak + keycloak-spring-security-adapter + + + + org.keycloak + keycloak-admin-client + ${keycloak.version} + + + + io.fabric8 + kubernetes-client + ${fabric8.version} + + + + org.etsi.osl + org.etsi.osl.model.tmf + ${org.etsi.osl.model.tmf.version} + + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.springdoc + springdoc-openapi-ui + ${springdoc.openapiui.version} + + + org.springdoc + springdoc-openapi-security + ${springdoc.security.version} + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + javax.annotation + javax.annotation-api + 1.3.2 + compile + + + jakarta.validation + jakarta.validation-api + 3.0.2 + + + org.jetbrains + annotations + 13.0 + compile + + + + + org.springframework.boot + spring-boot-starter-activemq + + + org.apache.activemq + activemq-amqp + test + + + org.apache.qpid + proton-j + + + + + org.messaginghub + pooled-jms + + + + + org.apache.camel.springboot + camel-spring-boot-starter + + + org.apache.activemq + activemq-pool + + + org.apache.camel + camel-activemq + + + org.apache.activemq + activemq-broker + + + + + org.apache.camel.springboot + camel-service-starter + + + + org.apache.camel.springboot + camel-http-starter + + + org.apache.camel + camel-jackson + + + org.apache.camel + camel-stream + + + + dk.brics.automaton + automaton + 1.11-8 + + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${jackson.version} + + + + + org.eclipse.jgit + org.eclipse.jgit + ${jgit.version} + + + + + com.networknt + json-schema-validator + 1.0.84 + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.etsi.osl + org.etsi.osl.model.k8s + ${org.etsi.osl.model.k8s.version} + + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + -parameters + + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok + ${lombok-version} + + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + + + org.codehaus.mojo + license-maven-plugin + ${maven-license-plugin.version} + + false + ========================LICENSE_START================================= + =========================LICENSE_END================================== + *.json + + + + generate-license-headers + + update-file-header + + process-sources + + ${license.licenseName} + + + + + download-licenses + + download-licenses + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot-version} + + + + repackage + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot-version} + + exec + + + + + + \ No newline at end of file diff --git a/src/main/java/org/etsi/osl/controllers/giter/GiterSpringBoot.java b/src/main/java/org/etsi/osl/controllers/giter/GiterSpringBoot.java new file mode 100644 index 0000000000000000000000000000000000000000..f1f173146648be4e88a539c18885e65f4ecb0cf5 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/GiterSpringBoot.java @@ -0,0 +1,80 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.tmf.api + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.ExitCodeGenerator; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableScheduling; + + +/** + * For implementing the callback and events, it might be useful to check the DDD pattern: + * https://www.baeldung.com/spring-data-ddd + * + * + * @author ctranoris + * + */ +@SpringBootApplication +@EntityScan(basePackages = {"org.etsi.osl.controllers.giter","org.etsi.osl.controllers.giter.api"}) +@EnableScheduling +public class GiterSpringBoot implements CommandLineRunner { + + private static ApplicationContext applicationContext; + + private static final Logger logger = LoggerFactory.getLogger("org.etsi.osl.controllers.giter"); + + + @Override + public void run(String... arg0) throws Exception { + if (arg0.length > 0 && arg0[0].equals("exitcode")) { + throw new ExitException(); + } + } + + public static void main(String[] args) throws Exception { + + logger.info("=========== STARTING org.etsi.osl.controllers.giter =============================="); + applicationContext = new SpringApplication(GiterSpringBoot.class).run(args); + + + // for (String beanName : applicationContext.getBeanDefinitionNames()) { + // System.out.println(beanName); + // } + } + + class ExitException extends RuntimeException implements ExitCodeGenerator { + private static final long serialVersionUID = 1L; + + @Override + public int getExitCode() { + return 10; + } + + } + +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/adapter/git/GitAdapter.java b/src/main/java/org/etsi/osl/controllers/giter/adapter/git/GitAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..8b6a849480e541521b49b3c86f7275034f9e7a07 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/adapter/git/GitAdapter.java @@ -0,0 +1,154 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.adapter.git; + +import java.io.IOException; +import org.etsi.osl.controllers.giter.model.CustomResource; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; + +/** + * Interface for Git operations. + * + * Implementations: JGit + * + * File path pattern: resources////.yaml + * Archive path pattern: archive///
/.yaml + * + * @author ctranoris + */ +public interface GitAdapter { + + /** + * Initialize the Git repository (clone if not exists, open if exists). + * + * @throws IOException if initialization fails + */ + void initialize() throws IOException; + + /** + * Write a Custom Resource to the repository. + * + * Path: resources////.yaml + * + * @param cr The Custom Resource to write + * @return The file path where the CR was written + * @throws IOException if write operation fails + */ + String writeCustomResource(GenericKubernetesResource cr) throws IOException; + + /** + * Write a Custom Resource to the repository with TMF Resource ID in filename. + * + * Path: resources////-.yaml + * + * @param cr The Custom Resource to write + * @param resourceId The TMF Resource ID to include in filename + * @return The file path where the CR was written + * @throws IOException if write operation fails + */ + String writeCustomResource(GenericKubernetesResource cr, String resourceId) throws IOException; + + /** + * Read a Custom Resource from the repository. + * + * @param group The CRD group + * @param version The CRD version + * @param kind The CRD kind + * @param name The resource name + * @return The Custom Resource, or null if not found + * @throws IOException if read operation fails + */ + CustomResource readCustomResource(String group, String version, String kind, String name) throws IOException; + + /** + * Delete a Custom Resource from the repository. + * + * Moves the file to archive///
/.yaml + * + * @param group The CRD group + * @param version The CRD version + * @param kind The CRD kind + * @param name The resource name + * @return true if deleted, false if not found + * @throws IOException if delete operation fails + */ + boolean deleteCustomResource(String group, String version, String kind, String name) throws IOException; + + /** + * Delete a Custom Resource by its file path. + * + * Moves the file to archive///
/ + * + * @param crFilePath The relative path to the CR file (e.g., resources/group/version/kind/name.yaml) + * @return true if deleted, false if not found + * @throws IOException if delete operation fails + */ + boolean deleteCustomResourceByPath(String crFilePath) throws IOException; + + /** + * Check if a Custom Resource exists in the repository. + * + * @param group The CRD group + * @param version The CRD version + * @param kind The CRD kind + * @param name The resource name + * @return true if exists, false otherwise + * @throws IOException if check fails + */ + boolean exists(String group, String version, String kind, String name) throws IOException; + + /** + * Commit changes to the local repository. + * + * @param message The commit message + * @param correlationId The correlation ID for tracking + * @return The commit ID + * @throws IOException if commit fails + */ + String commit(String message, String correlationId) throws IOException; + + /** + * Push local commits to the remote repository. + * + * Pulls before pushing to handle conflicts. + * + * @throws IOException if push fails + */ + void push() throws IOException; + + /** + * Pull changes from the remote repository. + * + * @throws IOException if pull fails + */ + void pull() throws IOException; + + /** + * Close the Git adapter and cleanup resources. + */ + void close(); + + /** + * Check if the repository is initialized and ready. + * + * @return true if ready, false otherwise + */ + boolean isReady(); +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/adapter/git/JGitAdapter.java b/src/main/java/org/etsi/osl/controllers/giter/adapter/git/JGitAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..7fd3e634871febf3363a781f26c8216c14bffe6a --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/adapter/git/JGitAdapter.java @@ -0,0 +1,416 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.adapter.git; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.PullResult; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.etsi.osl.controllers.giter.config.ProducerProperties; +import org.etsi.osl.controllers.giter.model.CustomResource; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * JGit implementation of GitAdapter. + * + * Manages Git repository for Custom Resources. + * + * @author ctranoris + */ +@Component +@Slf4j +public class JGitAdapter implements GitAdapter { + + private final ProducerProperties producerProperties; + private final ObjectMapper yamlMapper; + private Git git; + private boolean initialized = false; + + public JGitAdapter(ProducerProperties producerProperties) { + this.producerProperties = producerProperties; + this.yamlMapper = new ObjectMapper( + new YAMLFactory() + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + ); + log.info("JGitAdapter created"); + } + + @Override + public void initialize() throws IOException { + if (initialized) { + log.debug("JGitAdapter already initialized"); + return; + } + + String repositoryUrl = producerProperties.getGit().getRepositoryUrl(); + String localPath = producerProperties.getGit().getLocalPath(); + String branch = producerProperties.getGit().getBranch(); + + log.info("Initializing Git repository - URL: {}, Local: {}, Branch: {}", + repositoryUrl, localPath, branch); + + File localDir = new File(localPath); + + try { + if (localDir.exists() && new File(localDir, ".git").exists()) { + // Repository already exists, open it + log.info("Opening existing repository at: {}", localPath); + git = Git.open(localDir); + initialized = true; + + // Pull latest changes + log.info("Pulling latest changes from remote"); + pull(); + } else { + // Clone repository + log.info("Cloning repository from: {}", repositoryUrl); + + // Create parent directories if needed + localDir.mkdirs(); + + Git.cloneRepository() + .setURI(repositoryUrl) + .setDirectory(localDir) + .setBranch(branch) + .setCredentialsProvider(getCredentialsProvider()) + .call(); + + git = Git.open(localDir); + log.info("Repository cloned successfully"); + } + + initialized = true; + log.info("Git repository initialized successfully"); + + } catch (GitAPIException e) { + log.error("Failed to initialize Git repository", e); + throw new IOException("Git initialization failed", e); + } + } + + @Override + public String writeCustomResource(GenericKubernetesResource cr) throws IOException { + return writeCustomResource(cr, null); + } + + @Override + public String writeCustomResource(GenericKubernetesResource cr, String resourceId) throws IOException { + if (!initialized) { + throw new IllegalStateException("GitAdapter not initialized"); + } + + String group = extractGroup(cr.getApiVersion()); + String version = extractVersion(cr.getApiVersion()); + String kind = cr.getKind(); + String name = cr.getMetadata().getName(); + + // Construct file path: resources////-.yaml + // If resourceId is provided, append it to the filename + String fileName; + if (resourceId != null && !resourceId.isEmpty()) { + fileName = String.format("%s-%s.yaml", name, resourceId); + } else { + fileName = String.format("%s.yaml", name); + } + + String relativePath = String.format("resources/%s/%s/%s/%s", group, version, kind, fileName); + Path filePath = Paths.get(producerProperties.getGit().getLocalPath(), relativePath); + + log.info("Writing Custom Resource to: {}", relativePath); + + // Create directories if needed + Files.createDirectories(filePath.getParent()); + + // Write YAML file + yamlMapper.writeValue(filePath.toFile(), cr); + log.debug("Custom Resource written successfully: {}", filePath); + + return relativePath; + } + + @Override + public CustomResource readCustomResource(String group, String version, String kind, String name) throws IOException { + if (!initialized) { + throw new IllegalStateException("GitAdapter not initialized"); + } + + String relativePath = String.format("resources/%s/%s/%s/%s.yaml", group, version, kind, name); + Path filePath = Paths.get(producerProperties.getGit().getLocalPath(), relativePath); + + log.debug("Reading Custom Resource from: {}", relativePath); + + if (!Files.exists(filePath)) { + log.warn("Custom Resource not found: {}", relativePath); + return null; + } + + CustomResource cr = yamlMapper.readValue(filePath.toFile(), CustomResource.class); + log.debug("Custom Resource read successfully: {}", relativePath); + + return cr; + } + + @Override + public boolean deleteCustomResource(String group, String version, String kind, String name) throws IOException { + if (!initialized) { + throw new IllegalStateException("GitAdapter not initialized"); + } + + String relativePath = String.format("resources/%s/%s/%s/%s.yaml", group, version, kind, name); + Path filePath = Paths.get(producerProperties.getGit().getLocalPath(), relativePath); + + if (!Files.exists(filePath)) { + log.warn("Custom Resource not found for deletion: {}", relativePath); + return false; + } + + log.info("Deleting Custom Resource: {}", relativePath); + + // Archive the file before deletion + LocalDateTime now = LocalDateTime.now(); + String archivePath = String.format("archive/%s/%s/%s.yaml", + now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd")), + name); + Path archiveFilePath = Paths.get(producerProperties.getGit().getLocalPath(), archivePath); + + // Create archive directories + Files.createDirectories(archiveFilePath.getParent()); + + // Move file to archive + Files.move(filePath, archiveFilePath); + log.info("Custom Resource archived to: {}", archivePath); + + return true; + } + + @Override + public boolean deleteCustomResourceByPath(String crFilePath) throws IOException { + if (!initialized) { + throw new IllegalStateException("GitAdapter not initialized"); + } + + Path filePath = Paths.get(producerProperties.getGit().getLocalPath(), crFilePath); + + if (!Files.exists(filePath)) { + log.warn("Custom Resource not found for deletion: {}", crFilePath); + return false; + } + + log.info("Deleting Custom Resource: {}", crFilePath); + + // Extract filename and kind from path + // Path format: resources////.yaml + String fileName = filePath.getFileName().toString(); + String kind = filePath.getParent().getFileName().toString(); + + // Archive the file to archive- folder + String archivePath = String.format("archive-%s/%s", kind, fileName); + Path archiveFilePath = Paths.get(producerProperties.getGit().getLocalPath(), archivePath); + + // Create archive directories + Files.createDirectories(archiveFilePath.getParent()); + + // Move file to archive (this is a local file operation) + Files.move(filePath, archiveFilePath); + log.info("Custom Resource archived to: {}", archivePath); + + // Stage the deletion in Git (remove the old file from Git index) + try { + git.rm() + .addFilepattern(crFilePath) + .call(); + log.debug("Staged deletion of: {}", crFilePath); + + // Stage the new archived file + git.add() + .addFilepattern(archivePath) + .call(); + log.debug("Staged archive file: {}", archivePath); + } catch (GitAPIException e) { + log.error("Failed to stage Git changes for deletion", e); + throw new IOException("Failed to stage Git changes", e); + } + + return true; + } + + @Override + public boolean exists(String group, String version, String kind, String name) throws IOException { + if (!initialized) { + throw new IllegalStateException("GitAdapter not initialized"); + } + + String relativePath = String.format("resources/%s/%s/%s/%s.yaml", group, version, kind, name); + Path filePath = Paths.get(producerProperties.getGit().getLocalPath(), relativePath); + + return Files.exists(filePath); + } + + @Override + public String commit(String message, String correlationId) throws IOException { + if (!initialized) { + throw new IllegalStateException("GitAdapter not initialized"); + } + + try { + log.info("Committing changes - Message: {}, CorrelationId: {}", message, correlationId); + + // Add all changes + git.add() + .addFilepattern(".") + .call(); + + // Commit with correlation ID in message + String commitMessage = String.format("%s (correlation-id: %s)", message, correlationId); + var commit = git.commit() + .setMessage(commitMessage) + .call(); + + String commitId = commit.getId().abbreviate(7).name(); + log.info("Changes committed successfully - CommitId: {}", commitId); + + return commitId; + + } catch (GitAPIException e) { + log.error("Failed to commit changes", e); + throw new IOException("Git commit failed", e); + } + } + + @Override + public void push() throws IOException { + if (!initialized) { + throw new IllegalStateException("GitAdapter not initialized"); + } + + try { + log.info("Pushing changes to remote repository"); + + // Pull before push to handle conflicts + log.debug("Pulling latest changes before push"); + pull(); + + // Push changes + git.push() + .setCredentialsProvider(getCredentialsProvider()) + .call(); + + log.info("Changes pushed successfully to remote repository"); + + } catch (GitAPIException e) { + log.error("Failed to push changes", e); + throw new IOException("Git push failed", e); + } + } + + @Override + public void pull() throws IOException { + if (!initialized) { + throw new IllegalStateException("GitAdapter not initialized"); + } + + try { + log.debug("Pulling changes from remote repository"); + + PullResult result = git.pull() + .setCredentialsProvider(getCredentialsProvider()) + .call(); + + if (result.isSuccessful()) { + log.debug("Pull successful"); + } else { + log.warn("Pull completed with issues: {}", result.toString()); + } + + } catch (GitAPIException e) { + log.error("Failed to pull changes", e); + throw new IOException("Git pull failed", e); + } + } + + @Override + public void close() { + if (git != null) { + log.info("Closing Git repository"); + git.close(); + initialized = false; + } + } + + @Override + public boolean isReady() { + return initialized && git != null; + } + + /** + * Get credentials provider for Git authentication + */ + private UsernamePasswordCredentialsProvider getCredentialsProvider() { + ProducerProperties.GitProperties.CredentialsProperties creds = + producerProperties.getGit().getCredentials(); + + String username = creds.getUsername(); + String password = creds.getPassword(); + + // Use API key as password if username not provided + if ((username == null || username.isEmpty()) && creds.getApiKey() != null) { + username = "token"; + password = creds.getApiKey(); + } + + return new UsernamePasswordCredentialsProvider(username, password); + } + + /** + * Extract group from apiVersion (e.g., "example.com/v1" -> "example.com") + */ + private String extractGroup(String apiVersion) { + int slashIndex = apiVersion.indexOf('/'); + if (slashIndex > 0) { + return apiVersion.substring(0, slashIndex); + } + return apiVersion; + } + + /** + * Extract version from apiVersion (e.g., "example.com/v1" -> "v1") + */ + private String extractVersion(String apiVersion) { + int slashIndex = apiVersion.indexOf('/'); + if (slashIndex > 0 && slashIndex < apiVersion.length() - 1) { + return apiVersion.substring(slashIndex + 1); + } + return "v1"; + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/api/CatalogClient.java b/src/main/java/org/etsi/osl/controllers/giter/api/CatalogClient.java new file mode 100644 index 0000000000000000000000000000000000000000..f636a3c02478c80446e77885f7779a136223b4ad --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/api/CatalogClient.java @@ -0,0 +1,286 @@ +package org.etsi.osl.controllers.giter.api; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.camel.ProducerTemplate; +import org.apache.camel.builder.RouteBuilder; +import org.etsi.osl.tmf.rcm634.model.LogicalResourceSpecification; +import org.etsi.osl.tmf.rcm634.model.ResourceSpecification; +import org.etsi.osl.tmf.rcm634.model.ResourceSpecificationCreate; +import org.etsi.osl.tmf.ri639.model.LogicalResource; +import org.etsi.osl.tmf.ri639.model.Resource; +import org.etsi.osl.tmf.ri639.model.ResourceCreate; +import org.etsi.osl.tmf.ri639.model.ResourceUpdate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * Class to exchange information with TMF API services + * + */ +@Service +public class CatalogClient extends RouteBuilder{ + + + private static final Logger logger = LoggerFactory.getLogger("org.etsi.osl.controllers.giter"); + + @Autowired + private ProducerTemplate template; + + + @Value("${CATALOG_GET_RESOURCESPEC_BY_ID}") + private String CATALOG_GET_RESOURCESPEC_BY_ID = ""; + + @Value("${CATALOG_GET_RESOURCESPEC_BY_NAME_CATEGORY}") + private String CATALOG_GET_RESOURCESPEC_BY_NAME_CATEGORY = ""; + + + @Value("${CATALOG_UPDADD_RESOURCESPEC}") + private String CATALOG_UPDADD_RESOURCESPEC = ""; + + + @Value("${CATALOG_UPDADD_RESOURCE}") + private String CATALOG_UPDADD_RESOURCE = ""; + + + @Value("${CATALOG_UPD_RESOURCE}") + private String CATALOG_UPD_RESOURCE = ""; + + @Value("${CATALOG_GET_RESOURCE_BY_ID}") + private String CATALOG_GET_RESOURCE_BY_ID = ""; + + + //private ConcurrentHashMap resourcesToBeUpdated = new ConcurrentHashMap<>(); + + + @Override + public void configure() throws Exception { + + + +// from( "timer://processUpdateResources?period=5000" ) +// .log(LoggingLevel.INFO, log, " process processUpdateResources!") +// .to("log:DEBUG?showBody=true&showHeaders=true") +// .bean( "catalogClient", "processUpdateResources()"); + + + } + + + /** + * get service spec by id from model via bus + * @param id + * @return + * @throws IOException + */ + public ResourceSpecification retrieveResourceSpecByNameCategoryVersion(String aName, String aCategory, String aVersion) { + logger.debug("will retrieve Resource Specification aName=" + aName ); + + try { + Map map = new HashMap<>(); + map.put( "aname", aName); + map.put( "acategory", aCategory); + map.put( "aversion", aVersion); + Object response = + template.requestBodyAndHeaders( CATALOG_GET_RESOURCESPEC_BY_NAME_CATEGORY, null, map); + + if ( !(response instanceof String)) { + logger.error("Resource Specification object is wrong."); + return null; + } + LogicalResourceSpecification sor = toJsonObj( (String)response, LogicalResourceSpecification.class); + //logger.debug("retrieveSpec response is: " + response); + return sor; + + }catch (Exception e) { + logger.error("Cannot retrieve Resource Specification details from catalog. " + e.toString()); + } + return null; + } + + /** + * get service spec by id from model via bus + * @param id + * @return + * @throws IOException + */ + public ResourceSpecification retrieveResourceSpec(String specid) { + logger.debug("will retrieve Resource Specification id=" + specid ); + + try { + Object response = template. + requestBody( CATALOG_GET_RESOURCESPEC_BY_ID, specid); + + if ( !(response instanceof String)) { + logger.error("Resource Specification object is wrong."); + return null; + } + LogicalResourceSpecification sor = toJsonObj( (String)response, LogicalResourceSpecification.class); + //logger.debug("retrieveSpec response is: " + response); + return sor; + + }catch (Exception e) { + logger.error("Cannot retrieve Resource Specification details from catalog. " + e.toString()); + } + return null; + } + + + public LogicalResourceSpecification createOrUpdateResourceSpecByNameCategoryVersion( ResourceSpecificationCreate s) { + logger.debug("will createOrUpdateResourceSpecByNameCategoryVersion " ); + logger.debug("s= " + s ); + try { + Map map = new HashMap<>(); + map.put("aname", s.getName()); + map.put("aversion", s.getVersion()); + map.put("acategory", s.getCategory()); + + Object response = template.requestBodyAndHeaders( CATALOG_UPDADD_RESOURCESPEC, toJsonString(s), map); + + if ( !(response instanceof String)) { + logger.error("ResourceSpecification object is wrong."); + } + + LogicalResourceSpecification rs = toJsonObj( (String)response, LogicalResourceSpecification.class); + return rs; + + + }catch (Exception e) { + logger.error("Cannot create ResourceSpecification"); + e.printStackTrace(); + } + return null; + + } + + public Resource createOrUpdateResourceByNameCategoryVersion( ResourceCreate s) { + logger.debug("will createOrUpdateResourceByNameVersion a Resource " ); + try { + Map map = new HashMap<>(); + map.put("aname", s.getName()); + map.put("aversion", s.getResourceVersion()); + map.put("acategory", s.getCategory()); + + Object response = template.requestBodyAndHeaders( CATALOG_UPDADD_RESOURCE, toJsonString(s), map); + + if ( !(response instanceof String)) { + logger.error("Resource object is wrong."); + } + + logger.debug( response.toString() ); + try { + LogicalResource rs = toJsonObj( (String)response, LogicalResource.class); + return rs; + }catch (Exception e) { + logger.error("Cannot create LogicalResource"); + e.printStackTrace(); + } + + try { + Resource rs = toJsonObj( (String)response, Resource.class); + return rs; + }catch (Exception e) { + logger.error("Cannot create as Resource"); + e.printStackTrace(); + } + + + }catch (Exception e) { + logger.error("Cannot create Resource"); + e.printStackTrace(); + } + return null; + + } + + + + private T toJsonObj(String content, Class valueType) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + return mapper.readValue( content, valueType); + } + + private String toJsonString(Object object) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + return mapper.writeValueAsString(object); + } + + + + public Resource updateResourceById(String oslResourceId, ResourceUpdate rs) { + + + logger.debug("will update Resource : " + oslResourceId ); + try { + Map map = new HashMap<>(); + map.put("resourceId", oslResourceId ); + map.put("triggerServiceActionQueue", false ); + + Object response = template.requestBodyAndHeaders( CATALOG_UPD_RESOURCE, toJsonString(rs), map); + + if ( !(response instanceof String)) { + logger.error("Service Instance object is wrong."); + } + + LogicalResource resourceInstance = toJsonObj( (String)response, LogicalResource.class); + //logger.debug("createService response is: " + response); + return resourceInstance; + + + }catch (Exception e) { + e.printStackTrace(); + logger.error("Cannot update Service: " + oslResourceId + ": " + e.toString()); + } + return null; + } + +// public void updateResourceById(String oslResourceId, ResourceUpdate rs) { +// +// resourcesToBeUpdated.put(oslResourceId, rs); +// +// +// } + +// public void processUpdateResources() { +// +// resourcesToBeUpdated.forEach( (oslResourceId, rs) -> { +// +// logger.info("will update Resource : " + oslResourceId ); +// try { +// Map map = new HashMap<>(); +// map.put("resourceId", oslResourceId ); +// map.put("triggerServiceActionQueue", false ); +// String json = toJsonString(rs); +// Object response = template.requestBodyAndHeaders( CATALOG_UPD_RESOURCE, json, map); +// +// if ( !(response instanceof String)) { +// logger.error("Service Instance object is wrong."); +// } +// +// //LogicalResource resourceInstance = toJsonObj( (String)response, LogicalResource.class); +// //logger.debug("createService response is: " + response); +// //return resourceInstance; +// +// resourcesToBeUpdated.remove(oslResourceId); +// +// +// logger.info("Updated successfully Resource : " + oslResourceId ); +// }catch (Exception e) { +// e.printStackTrace(); +// logger.error("Cannot update Resource: " + oslResourceId + ": " + e.toString()); +// } +// +// +// +// }); +// } + +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/api/ControllerRouteBuillder.java b/src/main/java/org/etsi/osl/controllers/giter/api/ControllerRouteBuillder.java new file mode 100644 index 0000000000000000000000000000000000000000..f8ff16ecc333b1d39de460f72b37067aadca1269 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/api/ControllerRouteBuillder.java @@ -0,0 +1,133 @@ +package org.etsi.osl.controllers.giter.api; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.camel.CamelContext; +import org.apache.camel.LoggingLevel; +import org.apache.camel.ProducerTemplate; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.model.dataformat.JsonLibrary; +import org.etsi.osl.controllers.giter.config.ProducerProperties; +import org.etsi.osl.controllers.giter.service.ProducerService; +import org.etsi.osl.controllers.giter.service.ResourceMapper; +import org.etsi.osl.controllers.giter.service.ResourceRepoService; +import org.etsi.osl.tmf.rcm634.model.LogicalResourceSpecification; +import org.etsi.osl.tmf.ri639.model.Resource; +import org.etsi.osl.tmf.ri639.model.ResourceCreate; +import org.etsi.osl.tmf.ri639.model.ResourceUpdate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; + + +@Configuration +@Component +public class ControllerRouteBuillder extends RouteBuilder { + + private static final Logger logger = LoggerFactory.getLogger("org.etsi.osl.controllers.giter"); + + @Value("${spring.application.name}") + private String compname; + + @Autowired + private ProducerTemplate template; + + @Autowired + ResourceRepoService resourceRepoService; + + @Autowired + private ProducerProperties producerProperties; + + @Autowired + private CamelContext camelContext; + + @Override + public void configure() throws Exception { + // Do not configure routes here - they will be configured after bootstrap + // This is intentionally left empty to prevent NPE when accessing + // producerProperties.getRegisteredLogicalResourceSpecification() before it's set + logger.info("ControllerRouteBuillder.configure() called - routes will be initialized after bootstrap"); + } + + /** + * Initialize routes after CRD registration is complete. + * This method should be called from ProducerBootstrapService after + * registerCrdAsResourceSpecification() completes. + * + * @throws Exception if route configuration fails + */ + public void initializeRoutes() throws Exception { + logger.info("Initializing Camel routes for registered LogicalResourceSpecification"); + + LogicalResourceSpecification lrs = producerProperties.getRegisteredLogicalResourceSpecification(); + + if (lrs == null) { + logger.error("Cannot initialize routes - LogicalResourceSpecification not registered"); + throw new IllegalStateException("LogicalResourceSpecification not registered"); + } + + String EVENT_CREATE = "jms:queue:CREATE/"+ lrs.getCategory() +"/" + lrs.getVersion(); + String EVENT_UPDATE = "jms:queue:UPDATE/" + lrs.getCategory() +"/" + lrs.getVersion(); + String EVENT_DELETE = "jms:queue:DELETE/" + lrs.getCategory() +"/" + lrs.getVersion(); + + logger.info("Configuring routes for:"); + logger.info(" CREATE: {}", EVENT_CREATE); + logger.info(" UPDATE: {}", EVENT_UPDATE); + logger.info(" DELETE: {}", EVENT_DELETE); + + // Create and add routes dynamically + camelContext.addRoutes(new RouteBuilder() { + @Override + public void configure() throws Exception { + from(EVENT_CREATE) + .routeId("route-create-" + lrs.getCategory()) + .log(LoggingLevel.INFO, log, EVENT_CREATE + " message received!") + .to("log:DEBUG?showBody=true&showHeaders=true").unmarshal() + .json(JsonLibrary.Jackson, ResourceCreate.class, true) + .bean( resourceRepoService, "createResource( ${headers}, ${body} )") + .marshal().json( JsonLibrary.Jackson) + .convertBodyTo( String.class ); + + from(EVENT_UPDATE) + .routeId("route-update-" + lrs.getCategory()) + .log(LoggingLevel.INFO, log, EVENT_UPDATE + " message received!") + .to("log:DEBUG?showBody=true&showHeaders=true").unmarshal() + .json(JsonLibrary.Jackson, ResourceUpdate.class, true) + .bean( resourceRepoService, "updateResource( ${headers},${body} )") + .marshal().json( JsonLibrary.Jackson) + .convertBodyTo( String.class ); + + from(EVENT_DELETE) + .routeId("route-delete-" + lrs.getCategory()) + .log(LoggingLevel.INFO, log, EVENT_DELETE + " message received!") + .to("log:DEBUG?showBody=true&showHeaders=true").unmarshal() + .json(JsonLibrary.Jackson, ResourceUpdate.class, true) + .bean( resourceRepoService, "deleteResource( ${headers}, ${body} )") + .marshal().json( JsonLibrary.Jackson) + .convertBodyTo( String.class ); + } + }); + + logger.info("Camel routes initialized successfully"); + } + + static T toJsonObj(String content, Class valueType) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + return mapper.readValue(content, valueType); + } + + static String toJsonString(Object object) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + return mapper.writeValueAsString(object); + } + + +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/bootstrap/ProducerBootstrapService.java b/src/main/java/org/etsi/osl/controllers/giter/bootstrap/ProducerBootstrapService.java new file mode 100644 index 0000000000000000000000000000000000000000..d434f5bc8e32053cf470b355f3b50c0113c325b1 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/bootstrap/ProducerBootstrapService.java @@ -0,0 +1,272 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.bootstrap; + +import com.fasterxml.jackson.databind.JsonNode; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.client.utils.Serialization; +import lombok.extern.slf4j.Slf4j; +import java.io.IOException; +import java.util.List; +import org.etsi.osl.controllers.giter.adapter.git.GitAdapter; +import org.etsi.osl.controllers.giter.api.CatalogClient; +import org.etsi.osl.controllers.giter.api.ControllerRouteBuillder; +import org.etsi.osl.controllers.giter.config.ProducerProperties; +import org.etsi.osl.controllers.giter.config.SchemaLoaderConfig; +import org.etsi.osl.controllers.giter.service.ProducerService; +import org.etsi.osl.controllers.giter.service.ResourceMapper; +import org.etsi.osl.domain.model.kubernetes.KubernetesCRDV1; +import org.etsi.osl.tmf.rcm634.model.LogicalResourceSpecification; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import jakarta.annotation.PreDestroy; + +/** + * Bootstrap service for the Producer Service. + * + * Runs on application startup and performs: + * 1. Load CRD schema from configured file + * 2. Validate CRD schema (group, version, kind) + * 3. Initialize Git repository (clone or open) + * 4. Register CRD as TMF634 ResourceSpecification + * 5. Subscribe to messaging queues (CREATE, UPDATE, DELETE) + * + * @author ctranoris + */ +@Service +@Slf4j +public class ProducerBootstrapService { + + @Autowired + private ProducerProperties producerProperties; + + + @Autowired(required = false) + private GitAdapter gitAdapter; + + + @Autowired(required = false) + private ResourceMapper resourceMapper; + + @Autowired(required = false) + private CatalogClient catalogClient; + + @Autowired(required = false) + private SchemaLoaderConfig schemaLoaderConfig; + + @Autowired(required = false) + private ControllerRouteBuillder controllerRouteBuillder; + + + /** + * Execute bootstrap logic on application startup + */ + @EventListener(ApplicationStartedEvent.class) + public void onApplicationStarted() { + log.info("Starting Producer Service Bootstrap"); + + try { + // Step 1: Load and validate CRD schema + validateCrdSchema(); + + // Step 2: Log configuration + logConfiguration(); + + // Step 3: Initialize Git repository (placeholder) + initializeGitRepository(); + + // Step 4: Register CRD as TMF634 ResourceSpecification (placeholder) + registerCrdAsResourceSpecification(); + + // Step 5: Initialize Camel routes after CRD registration is complete + initializeCamelRoutes(); + + log.info("Producer Service Bootstrap completed successfully"); + } catch (Exception e) { + log.error("Bootstrap failed", e); + throw new RuntimeException("Bootstrap failed", e); + } + } + + /** + * Validate CRD schema contains required fields + */ + private void validateCrdSchema() { + log.info("Validating CRD Schema"); + + JsonNode crdSchema = null; + try { + crdSchema = schemaLoaderConfig.crdSchema( producerProperties.getCrd().getSchemaFile() ); + + if (crdSchema == null) { + log.error("CRD schema not loaded. Skipping schema validation."); + return; + } + } catch (IOException e) { + log.error("CRD schema not loaded. Skipping schema validation."); + e.printStackTrace(); + } + + CustomResourceDefinition exchangedCRD = null; + try { + + exchangedCRD = Serialization + .unmarshal(Serialization.asJson(crdSchema), CustomResourceDefinition.class); + + producerProperties.setExchangedCRD(exchangedCRD); + + String group = exchangedCRD.getSpec().getGroup(); + String version = exchangedCRD.getSpec().getVersions().get(0).getName() ; + String kind = exchangedCRD.getKind(); + + if (group == null || group.isEmpty()) { + throw new IllegalArgumentException("CRD group is not configured"); + } + if (version == null || version.isEmpty()) { + throw new IllegalArgumentException("CRD version is not configured"); + } + if (kind == null || kind.isEmpty()) { + throw new IllegalArgumentException("CRD kind is not configured"); + } + + log.info("CRD validation passed - Group: {}, Version: {}, Kind: {}", group, version, kind); + + + } catch (Exception e) { + log.error("Cannot register CustomResource - Kind: {}", exchangedCRD.getKind() ); + return; + } + + + + } + + /** + * Log the loaded configuration for debugging + */ + private void logConfiguration() { + log.info("Producer Configuration:"); + log.info(" Git Repository URL: {}", producerProperties.getGit().getRepositoryUrl()); + log.info(" Git Local Path: {}", producerProperties.getGit().getLocalPath()); + log.info(" Git Branch: {}", producerProperties.getGit().getBranch()); + log.info(" CRD Group: {}", producerProperties.getExchangedCRD().getSpec().getGroup()); + log.info(" CRD Kind: {}", producerProperties.getExchangedCRD().getKind()); + } + + /** + * Initialize Git repository (clone if not exists, or open existing) + */ + private void initializeGitRepository() { + log.info("Initializing Git Repository"); + + if (gitAdapter == null) { + log.warn("GitAdapter not configured. Skipping Git initialization."); + return; + } + + String repositoryUrl = producerProperties.getGit().getRepositoryUrl(); + String localPath = producerProperties.getGit().getLocalPath(); + + if (repositoryUrl == null || repositoryUrl.isEmpty()) { + log.warn("Git repository URL not configured. Skipping Git initialization."); + return; + } + + log.debug("Git Repository URL: {}", repositoryUrl); + log.debug("Git Local Path: {}", localPath); + + try { + gitAdapter.initialize(); + log.info("Git repository initialized successfully"); + } catch (Exception e) { + log.error("Failed to initialize Git repository", e); + throw new RuntimeException("Git initialization failed", e); + } + } + + /** + * Register CRD as TMF634 ResourceSpecification + * TODO: Implement with TMF634Client adapter + */ + private void registerCrdAsResourceSpecification() { + log.info("Registering CRD as TMF634 ResourceSpecification"); + + List rspec = resourceMapper.KubernetesCRD2OpensliceCRD( producerProperties.getExchangedCRD() ); + for (KubernetesCRDV1 rs : rspec) { + LogicalResourceSpecification lrs =catalogClient.createOrUpdateResourceSpecByNameCategoryVersion(resourceMapper.toRSpecCreate( rs )); + //catalogClient.createOrUpdateResourceByNameCategoryVersion( rs.toResourceCreate() ); + + //warning only one for now + producerProperties.setRegisteredLogicalResourceSpecification(lrs); + } + + } + + /** + * Initialize Camel routes after CRD registration is complete. + * This ensures that routes are configured with the correct queue names + * based on the registered LogicalResourceSpecification. + */ + private void initializeCamelRoutes() { + log.info("Initializing Camel routes"); + + if (controllerRouteBuillder == null) { + log.warn("ControllerRouteBuillder not configured. Skipping route initialization."); + return; + } + + if (producerProperties.getRegisteredLogicalResourceSpecification() == null) { + log.error("Cannot initialize routes - LogicalResourceSpecification not registered"); + throw new RuntimeException("LogicalResourceSpecification not registered before route initialization"); + } + + try { + controllerRouteBuillder.initializeRoutes(); + log.info("Camel routes initialized successfully"); + } catch (Exception e) { + log.error("Failed to initialize Camel routes", e); + throw new RuntimeException("Camel route initialization failed", e); + } + } + + /** + * Cleanup on application shutdown + */ + @PreDestroy + private void stop() { + log.info("Stopping Producer Service"); + + + // Close Git adapter + if (gitAdapter != null) { + try { + gitAdapter.close(); + log.info("GitAdapter closed successfully"); + } catch (Exception e) { + log.error("Failed to close GitAdapter", e); + } + } + + // TODO: Close HTTP clients (Phase 4) + log.info("Producer Service stopped"); + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/config/ActiveMQComponentConfig.java b/src/main/java/org/etsi/osl/controllers/giter/config/ActiveMQComponentConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..7be5e1f4e11991869ddc08cbd578cf90831c0865 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/config/ActiveMQComponentConfig.java @@ -0,0 +1,22 @@ +package org.etsi.osl.controllers.giter.config; + +import org.apache.camel.component.activemq.ActiveMQComponent; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import jakarta.jms.ConnectionFactory; + +/** + * @author ctranoris + * + */ +@Configuration +public class ActiveMQComponentConfig { + + @Bean(name = "activemq") + public ActiveMQComponent createComponent(ConnectionFactory factory) { + ActiveMQComponent activeMQComponent = new ActiveMQComponent(); + activeMQComponent.setConnectionFactory(factory); + return activeMQComponent; + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/config/ProducerProperties.java b/src/main/java/org/etsi/osl/controllers/giter/config/ProducerProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..5531d2c349cb10cac2039b38ededd8de957d2862 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/config/ProducerProperties.java @@ -0,0 +1,138 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.config; + +import lombok.Data; +import org.etsi.osl.tmf.rcm634.model.LogicalResourceSpecification; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Configuration properties for the Producer Service. + * Binds to prefix 'producer' in application.yml + * + * @author ctranoris + */ +@Component +@ConfigurationProperties(prefix = "producer") +@Data +public class ProducerProperties { + + private GitProperties git = new GitProperties(); + + private CrdProperties crd = new CrdProperties(); + + + private CustomResourceDefinition exchangedCRD; + + private LogicalResourceSpecification registeredLogicalResourceSpecification; + + private StatusWatcherProperties statusWatcher = new StatusWatcherProperties(); + + /** + * Git repository configuration + */ + @Data + public static class GitProperties { + private String repositoryUrl; + private String localPath = "/tmp/resources-repo"; + private String branch = "main"; + private CredentialsProperties credentials = new CredentialsProperties(); + + @Data + public static class CredentialsProperties { + private String username; + private String password; + private String apiKey; + } + } + + /** + * Custom Resource Definition (CRD) configuration + */ + @Data + public static class CrdProperties { + private String schemaFile = "classpath:crd-schema.yaml"; + private String statusFieldPath = "status.state"; + } + + + + /** + * Status Watcher configuration + */ + @Data + public static class StatusWatcherProperties { + private long pollIntervalMs = 1000; + private long timeoutMs = 30000; + + /** + * Path to persist reconciliation tasks. + * Default is .giter inside git local path. + * Can be overridden to use a separate directory. + */ + private String persistencePath; + + /** + * List of status mappings from CR status to TMF types. + * Can be configured via environment variables as: + * PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_CR_STATUS=Running + * PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_RESOURCE_STATUS_TYPE=AVAILABLE + * PRODUCER_STATUS_WATCHER_STATUS_MAPPINGS_0_OPERATIONAL_STATE_TYPE=ENABLE + */ + private List statusMappings = new java.util.ArrayList<>(); + + /** + * Default ResourceStatusType when no mapping is found + */ + private String defaultResourceStatusType = "UNKNOWN"; + + /** + * Default ResourceOperationalStateType when no mapping is found + */ + private String defaultOperationalStateType = "DISABLE"; + } + + /** + * Mapping configuration for a single CR status value + */ + @Data + public static class StatusMapping { + /** + * CR status value to match (e.g., "Running", "READY", "Failed") + */ + private String crStatus; + + /** + * TMF ResourceStatusType: STANDBY, SUSPEND, AVAILABLE, RESERVED, UNKNOWN + */ + private String resourceStatusType; + + /** + * TMF ResourceOperationalStateType: ENABLE, DISABLE + */ + private String operationalStateType; + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/config/SchemaLoaderConfig.java b/src/main/java/org/etsi/osl/controllers/giter/config/SchemaLoaderConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..bc58c71fb8ebca20bda90ef87a8436097e04b05d --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/config/SchemaLoaderConfig.java @@ -0,0 +1,96 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Configuration for loading and parsing CRD (Custom Resource Definition) schemas. + * Supports both YAML and JSON formats. + * + * @author ctranoris + */ +@Configuration +@Slf4j +public class SchemaLoaderConfig { + + @Autowired + private ResourceLoader resourceLoader; + + /** + * Load the CRD schema from the configured file path. + * Supports both YAML (classpath:crd-schema.yaml) and JSON formats. + * + * @return JsonNode representing the CRD schema + * @throws IOException if the schema file cannot be read + */ + public JsonNode crdSchema( String schemaFile ) throws IOException { + log.info("Loading CRD schema from: {}", schemaFile); + + Resource resource = resourceLoader.getResource(schemaFile); + + + if (!resource.exists()) { + throw new IOException("CRD schema file not found: " + schemaFile); + } + + try (InputStream inputStream = resource.getInputStream()) { + if (schemaFile.endsWith(".yaml") || schemaFile.endsWith(".yml")) { + return loadYamlSchema(inputStream); + } else if (schemaFile.endsWith(".json")) { + return loadJsonSchema(inputStream); + } else { + throw new IOException("Unsupported schema format. Expected .yaml, .yml, or .json: " + schemaFile); + } + } + } + + /** + * Load schema from YAML format + */ + private JsonNode loadYamlSchema(InputStream inputStream) throws IOException { + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + JsonNode schema = yamlMapper.readTree(inputStream); + log.debug("Successfully loaded YAML CRD schema"); + return schema; + } + + /** + * Load schema from JSON format + */ + private JsonNode loadJsonSchema(InputStream inputStream) throws IOException { + ObjectMapper jsonMapper = new ObjectMapper(); + JsonNode schema = jsonMapper.readTree(inputStream); + log.debug("Successfully loaded JSON CRD schema"); + return schema; + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/exception/GitOperationException.java b/src/main/java/org/etsi/osl/controllers/giter/exception/GitOperationException.java new file mode 100644 index 0000000000000000000000000000000000000000..c5587669649847f814fc5e158d1c09e3046194cf --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/exception/GitOperationException.java @@ -0,0 +1,139 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.exception; + +/** + * Exception thrown when Git operations fail. + * + * Covers failures in: + * - Clone/initialization + * - Read/write operations + * - Commit operations + * - Push/pull operations + * - Conflict resolution + * + * @author ctranoris + */ +public class GitOperationException extends ProducerException { + + private final GitOperation operation; + private final String filePath; + private final boolean retryable; + + public GitOperationException(String message, GitOperation operation) { + super(message, null, ErrorType.GIT_OPERATION); + this.operation = operation; + this.filePath = null; + this.retryable = false; + } + + public GitOperationException(String message, GitOperation operation, Throwable cause) { + super(message, null, ErrorType.GIT_OPERATION, cause); + this.operation = operation; + this.filePath = null; + this.retryable = false; + } + + public GitOperationException(String message, String correlationId, GitOperation operation) { + super(message, correlationId, ErrorType.GIT_OPERATION); + this.operation = operation; + this.filePath = null; + this.retryable = false; + } + + public GitOperationException(String message, String correlationId, GitOperation operation, Throwable cause) { + super(message, correlationId, ErrorType.GIT_OPERATION, cause); + this.operation = operation; + this.filePath = null; + this.retryable = false; + } + + public GitOperationException(String message, String correlationId, GitOperation operation, String filePath, boolean retryable) { + super(message, correlationId, ErrorType.GIT_OPERATION); + this.operation = operation; + this.filePath = filePath; + this.retryable = retryable; + } + + public GitOperationException(String message, String correlationId, GitOperation operation, String filePath, boolean retryable, Throwable cause) { + super(message, correlationId, ErrorType.GIT_OPERATION, cause); + this.operation = operation; + this.filePath = filePath; + this.retryable = retryable; + } + + public GitOperation getOperation() { + return operation; + } + + public String getFilePath() { + return filePath; + } + + public boolean isRetryable() { + return retryable; + } + + /** + * Git operations that can fail. + */ + public enum GitOperation { + CLONE("Clone repository"), + OPEN("Open repository"), + PULL("Pull changes"), + PUSH("Push changes"), + COMMIT("Commit changes"), + READ("Read file"), + WRITE("Write file"), + DELETE("Delete file"), + CONFLICT("Resolve conflict"), + AUTHENTICATION("Authenticate"); + + private final String description; + + GitOperation(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(getClass().getSimpleName()); + if (operation != null) { + sb.append(" [operation=").append(operation).append("]"); + } + if (getCorrelationId() != null) { + sb.append(" [correlationId=").append(getCorrelationId()).append("]"); + } + if (filePath != null) { + sb.append(" [file=").append(filePath).append("]"); + } + if (retryable) { + sb.append(" [retryable]"); + } + sb.append(": ").append(getMessage()); + return sb.toString(); + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/exception/ProducerException.java b/src/main/java/org/etsi/osl/controllers/giter/exception/ProducerException.java new file mode 100644 index 0000000000000000000000000000000000000000..a2aebd2ae04f833473e4a748174c3d4cd15277d3 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/exception/ProducerException.java @@ -0,0 +1,108 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.exception; + +/** + * Base exception for all Producer Service errors. + * + * Provides correlation ID tracking for distributed tracing + * and structured error handling. + * + * @author ctranoris + */ +public class ProducerException extends Exception { + + private final String correlationId; + private final ErrorType errorType; + + public ProducerException(String message) { + super(message); + this.correlationId = null; + this.errorType = ErrorType.UNKNOWN; + } + + public ProducerException(String message, Throwable cause) { + super(message, cause); + this.correlationId = null; + this.errorType = ErrorType.UNKNOWN; + } + + public ProducerException(String message, String correlationId) { + super(message); + this.correlationId = correlationId; + this.errorType = ErrorType.UNKNOWN; + } + + public ProducerException(String message, String correlationId, Throwable cause) { + super(message, cause); + this.correlationId = correlationId; + this.errorType = ErrorType.UNKNOWN; + } + + public ProducerException(String message, String correlationId, ErrorType errorType) { + super(message); + this.correlationId = correlationId; + this.errorType = errorType; + } + + public ProducerException(String message, String correlationId, ErrorType errorType, Throwable cause) { + super(message, cause); + this.correlationId = correlationId; + this.errorType = errorType; + } + + public String getCorrelationId() { + return correlationId; + } + + public ErrorType getErrorType() { + return errorType; + } + + /** + * Error types for categorizing failures. + */ + public enum ErrorType { + UNKNOWN, + VALIDATION, + GIT_OPERATION, + MESSAGING, + SCHEMA_LOADING, + RESOURCE_MAPPING, + CONFIGURATION, + NETWORK, + AUTHENTICATION, + TIMEOUT + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(getClass().getSimpleName()); + if (correlationId != null) { + sb.append(" [correlationId=").append(correlationId).append("]"); + } + if (errorType != null && errorType != ErrorType.UNKNOWN) { + sb.append(" [type=").append(errorType).append("]"); + } + sb.append(": ").append(getMessage()); + return sb.toString(); + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/exception/SchemaValidationException.java b/src/main/java/org/etsi/osl/controllers/giter/exception/SchemaValidationException.java new file mode 100644 index 0000000000000000000000000000000000000000..a75ba85f43242736efdd4ceef4cdb1ae0fbfe5b9 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/exception/SchemaValidationException.java @@ -0,0 +1,90 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.exception; + +import java.util.List; + +/** + * Exception thrown when CRD schema validation fails. + * + * Contains detailed validation error messages and the correlation ID + * for tracking the failed request. + * + * @author ctranoris + */ +public class SchemaValidationException extends ProducerException { + + private final List validationErrors; + private final String resourceName; + + public SchemaValidationException(String message, String correlationId) { + super(message, correlationId, ErrorType.VALIDATION); + this.validationErrors = null; + this.resourceName = null; + } + + public SchemaValidationException(String message, String correlationId, List validationErrors) { + super(buildMessage(message, validationErrors), correlationId, ErrorType.VALIDATION); + this.validationErrors = validationErrors; + this.resourceName = null; + } + + public SchemaValidationException(String message, String correlationId, String resourceName, List validationErrors) { + super(buildMessage(message, resourceName, validationErrors), correlationId, ErrorType.VALIDATION); + this.validationErrors = validationErrors; + this.resourceName = resourceName; + } + + public List getValidationErrors() { + return validationErrors; + } + + public String getResourceName() { + return resourceName; + } + + /** + * Build detailed error message including validation errors. + */ + private static String buildMessage(String message, List validationErrors) { + if (validationErrors == null || validationErrors.isEmpty()) { + return message; + } + StringBuilder sb = new StringBuilder(message); + sb.append(". Validation errors: "); + sb.append(String.join("; ", validationErrors)); + return sb.toString(); + } + + /** + * Build detailed error message including resource name and validation errors. + */ + private static String buildMessage(String message, String resourceName, List validationErrors) { + StringBuilder sb = new StringBuilder(message); + if (resourceName != null) { + sb.append(" [resource=").append(resourceName).append("]"); + } + if (validationErrors != null && !validationErrors.isEmpty()) { + sb.append(". Validation errors: "); + sb.append(String.join("; ", validationErrors)); + } + return sb.toString(); + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/model/CustomResource.java b/src/main/java/org/etsi/osl/controllers/giter/model/CustomResource.java new file mode 100644 index 0000000000000000000000000000000000000000..46e368345bcd94cb4987ef0b86e1c957f8f77cb3 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/model/CustomResource.java @@ -0,0 +1,138 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Kubernetes-style Custom Resource model. + * + * Structure: + * apiVersion: / + * kind: + * metadata: + * name: + * namespace: + * labels: {...} + * spec: {...} + * status: {...} + * + * @author ctranoris + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CustomResource { + + /** + * API version in format: / + * Example: example.com/v1 + */ + @JsonProperty("apiVersion") + private String apiVersion; + + /** + * Kind of the resource + * Example: CronTab + */ + @JsonProperty("kind") + private String kind; + + /** + * Metadata for the resource + */ + @JsonProperty("metadata") + @Builder.Default + private Metadata metadata = new Metadata(); + + /** + * Spec field (written by Producer) + * Contains the desired state of the resource + */ + @JsonProperty("spec") + @Builder.Default + private Map spec = new LinkedHashMap<>(); + + /** + * Status field (written by Consumer) + * Contains the observed state of the resource + */ + @JsonProperty("status") + private Map status; + + /** + * Metadata substructure + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Metadata { + + /** + * Name of the resource (required) + */ + @JsonProperty("name") + private String name; + + /** + * Namespace (optional, defaults to "default") + */ + @JsonProperty("namespace") + @Builder.Default + private String namespace = "default"; + + /** + * Labels for the resource + */ + @JsonProperty("labels") + @Builder.Default + private Map labels = new LinkedHashMap<>(); + + /** + * Annotations for the resource + */ + @JsonProperty("annotations") + private Map annotations; + + /** + * Generation number (incremented on spec changes) + */ + @JsonProperty("generation") + private Long generation; + + /** + * Creation timestamp + */ + @JsonProperty("creationTimestamp") + private String creationTimestamp; + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/model/ReconciliationTask.java b/src/main/java/org/etsi/osl/controllers/giter/model/ReconciliationTask.java new file mode 100644 index 0000000000000000000000000000000000000000..9288b00ece56d2cd775e0c95702113730f33ee32 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/model/ReconciliationTask.java @@ -0,0 +1,162 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.Map; + +/** + * Represents a reconciliation task for monitoring CR status changes. + * This is persisted to disk so reconciliation can resume after service restart. + * + * @author ctranoris + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReconciliationTask { + + /** + * Unique identifier for this reconciliation task + */ + @JsonProperty("taskId") + private String taskId; + + /** + * TMF639 Resource ID to update when status changes + */ + @JsonProperty("tmfResourceId") + private String tmfResourceId; + + /** + * CR apiVersion (e.g., "example.com/v1") + */ + @JsonProperty("apiVersion") + private String apiVersion; + + /** + * CR kind + */ + @JsonProperty("kind") + private String kind; + + /** + * CR metadata name + */ + @JsonProperty("crName") + private String crName; + + /** + * Path to the CR file in Git repository + */ + @JsonProperty("crFilePath") + private String crFilePath; + + /** + * Last known status of the CR (to detect changes) + */ + @JsonProperty("lastKnownStatus") + private Map lastKnownStatus; + + /** + * Timestamp when reconciliation started + */ + @JsonProperty("startedAt") + private Instant startedAt; + + /** + * Timestamp when reconciliation should timeout + */ + @JsonProperty("timeoutAt") + private Instant timeoutAt; + + /** + * Last time Git was polled for this CR + */ + @JsonProperty("lastPollAt") + private Instant lastPollAt; + + /** + * Number of polls performed + */ + @JsonProperty("pollCount") + @Builder.Default + private int pollCount = 0; + + /** + * Current state of reconciliation + */ + @JsonProperty("state") + @Builder.Default + private ReconciliationState state = ReconciliationState.PENDING; + + /** + * Error message if reconciliation failed + */ + @JsonProperty("errorMessage") + private String errorMessage; + + /** + * Reconciliation states + */ + public enum ReconciliationState { + PENDING, // Waiting to start polling + ACTIVE, // Currently polling for status changes + COMPLETED, // Status changed and TMF updated successfully + TIMEOUT, // Timeout reached without status change + FAILED // Error occurred during reconciliation + } + + /** + * Check if the task has timed out + */ + @JsonIgnore + public boolean isTimedOut() { + return Instant.now().isAfter(timeoutAt); + } + + /** + * Check if the task is still active (should be polled) + */ + @JsonIgnore + public boolean isActive() { + return state == ReconciliationState.PENDING || state == ReconciliationState.ACTIVE; + } + + /** + * Check if enough time has passed since last poll + */ + public boolean shouldPoll(long pollIntervalMs) { + if (lastPollAt == null) { + return true; + } + return Instant.now().isAfter(lastPollAt.plusMillis(pollIntervalMs)); + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/service/ProducerService.java b/src/main/java/org/etsi/osl/controllers/giter/service/ProducerService.java new file mode 100644 index 0000000000000000000000000000000000000000..7f165fbe1eb1654f6f09d839cceb0110604879c6 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/service/ProducerService.java @@ -0,0 +1,219 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.service; + +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.etsi.osl.controllers.giter.adapter.git.GitAdapter; +import org.etsi.osl.controllers.giter.config.ProducerProperties; +import org.etsi.osl.controllers.giter.exception.SchemaValidationException; +import org.etsi.osl.controllers.giter.model.CustomResource; +import org.etsi.osl.tmf.ri639.model.ResourceUpdate; +import org.springframework.stereotype.Service; + +import java.util.Collections; + +/** + * Core business logic for the Producer Service. + * + * Handles CREATE, UPDATE, and DELETE operations for TMF639 Resources: + * 1. Map TMF639 Resource to Custom Resource + * 2. Validate against CRD schema + * 3. Write to Git repository + * 4. Commit and push changes + * + * @author ctranoris + */ +@Service +@Slf4j +public class ProducerService { + + private final ResourceMapper resourceMapper; + private final SchemaValidator schemaValidator; + private final ProducerProperties producerProperties; + + public ProducerService( + ResourceMapper resourceMapper, + SchemaValidator schemaValidator, + GitAdapter gitAdapter, + ProducerProperties producerProperties ) { + this.resourceMapper = resourceMapper; + this.schemaValidator = schemaValidator; + this.producerProperties = producerProperties; + log.info("ProducerService initialized with metrics support"); + } + + + + + /** + * Handle UPDATE operation for a TMF639 Resource. + * + * Flow: + * 1. Read existing Custom Resource from Git + * 2. Update CR spec from TMF639 Resource + * 3. Validate updated CR against CRD schema + * 4. Write updated CR to Git repository + * 5. Commit with correlation ID + * 6. Push to remote + * + * @param message The producer message containing TMF639 Resource + * @throws Exception if operation fails + */ + public void onUpdate(ResourceUpdate ru) throws Exception { + + log.info("Processing UPDATE operation - Resource name: {}", ru.getName() ); + +// try { +// +// String group = producerProperties.getCrd().getGroup(); +// String version = producerProperties.getCrd().getVersion(); +// String kind = producerProperties.getCrd().getKind(); +// String resourceName = message.getTmfResource().getId().toLowerCase() +// .replaceAll("[^a-z0-9.-]", "-"); +// +// // Step 1: Read existing Custom Resource +// CustomResource existingCr = gitAdapter.readCustomResource(group, version, kind, resourceName); +// if (existingCr == null) { +// log.warn("Custom Resource not found for UPDATE, creating new - CorrelationId: {}", +// correlationId); +// // Fall back to CREATE +// onCreate(message); +// return; +// } +// +// // Step 2: Update Custom Resource from TMF639 Resource +// CustomResource updatedCr = resourceMapper.updateCustomResource(existingCr, message.getTmfResource()); +// +// // Step 3: Validate updated Custom Resource +// SchemaValidator.ValidationResult validation = schemaValidator.validate(updatedCr); +// if (validation.isFailure()) { +// log.error("Validation failed for UPDATE - CorrelationId: {}, Errors: {}", +// correlationId, validation.getErrorMessage()); +// metrics.recordValidationFailure(); +// throw new SchemaValidationException( +// "Schema validation failed for UPDATE", +// correlationId, +// updatedCr.getMetadata().getName(), +// Collections.singletonList(validation.getErrorMessage()) +// ); +// } +// metrics.recordValidationSuccess(); +// +// // Step 4: Write updated Custom Resource to Git +// String filePath = gitAdapter.writeCustomResource(updatedCr); +// log.info("Custom Resource updated in Git - Path: {}, CorrelationId: {}", +// filePath, correlationId); +// +// // Step 5: Commit changes +// Timer.Sample commitSample = metrics.startGitCommitTimer(); +// String commitMessage = String.format("[UPDATE] %s %s", +// updatedCr.getKind(), updatedCr.getMetadata().getName()); +// String commitId = gitAdapter.commit(commitMessage, correlationId); +// metrics.recordGitCommitComplete(commitSample); +// log.info("Changes committed - CommitId: {}, CorrelationId: {}", commitId, correlationId); +// +// // Step 6: Push to remote +// Timer.Sample pushSample = metrics.startGitPushTimer(); +// gitAdapter.push(); +// metrics.recordGitPushComplete(pushSample); +// metrics.recordGitPush(); +// log.info("Changes pushed to remote - CorrelationId: {}", correlationId); +// +// // Record success metrics +// metrics.recordCrUpdate(); +// metrics.recordMessageProcessed(); +// metrics.recordMessageProcessingComplete(sample); +// +// log.info("UPDATE operation completed successfully - CorrelationId: {}", correlationId); +// +// } catch (Exception e) { +// log.error("UPDATE operation failed - CorrelationId: {}", correlationId, e); +// metrics.recordMessageFailed(); +// throw e; +// } finally { +// +// } + } + + /** + * Handle DELETE operation for a TMF639 Resource. + * + * Flow: + * 1. Delete Custom Resource from Git (moves to archive) + * 2. Commit with correlation ID + * 3. Push to remote + * + * @param message The producer message containing TMF639 Resource + * @throws Exception if operation fails + */ + public void onDelete(ResourceUpdate ru) throws Exception { + log.info("Processing DELETE operation - Resource name: {}", ru.getName() ); + +// try { +// +// +// String group = producerProperties.getCrd().getGroup(); +// String version = producerProperties.getCrd().getVersion(); +// String kind = producerProperties.getCrd().getKind(); +// String resourceName = message.getTmfResource().getId().toLowerCase() +// .replaceAll("[^a-z0-9.-]", "-"); +// +// // Step 1: Delete Custom Resource (moves to archive) +// boolean deleted = gitAdapter.deleteCustomResource(group, version, kind, resourceName); +// if (!deleted) { +// log.warn("Custom Resource not found for DELETE - CorrelationId: {}", correlationId); +// // Not an error, resource may have been already deleted (idempotent) +// metrics.recordMessageProcessed(); +// metrics.recordMessageProcessingComplete(sample); +// return; +// } +// +// log.info("Custom Resource deleted (archived) - CorrelationId: {}", correlationId); +// +// // Step 2: Commit changes +// Timer.Sample commitSample = metrics.startGitCommitTimer(); +// String commitMessage = String.format("[DELETE] %s %s", kind, resourceName); +// String commitId = gitAdapter.commit(commitMessage, correlationId); +// metrics.recordGitCommitComplete(commitSample); +// log.info("Changes committed - CommitId: {}, CorrelationId: {}", commitId, correlationId); +// +// // Step 3: Push to remote +// Timer.Sample pushSample = metrics.startGitPushTimer(); +// gitAdapter.push(); +// metrics.recordGitPushComplete(pushSample); +// metrics.recordGitPush(); +// log.info("Changes pushed to remote - CorrelationId: {}", correlationId); +// +// // Record success metrics +// metrics.recordCrDelete(); +// metrics.recordMessageProcessed(); +// metrics.recordMessageProcessingComplete(sample); +// +// log.info("DELETE operation completed successfully - CorrelationId: {}", correlationId); +// +// } catch (Exception e) { +// log.error("DELETE operation failed - CorrelationId: {}", correlationId, e); +// metrics.recordMessageFailed(); +// throw e; +// } finally { +// } + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/service/ResourceMapper.java b/src/main/java/org/etsi/osl/controllers/giter/service/ResourceMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..1f778e1ebf851a8220f8ffef21a3410de92a0ca4 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/service/ResourceMapper.java @@ -0,0 +1,310 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.service; + +import lombok.extern.slf4j.Slf4j; +import org.etsi.osl.tmf.ri639.model.Resource; +import org.etsi.osl.tmf.ri639.model.ResourceUpdate; +import org.etsi.osl.controllers.giter.config.ProducerProperties; +import org.etsi.osl.controllers.giter.model.CustomResource; +import org.etsi.osl.domain.model.kubernetes.KubernetesCRDProperty; +import org.etsi.osl.domain.model.kubernetes.KubernetesCRDV1; +import org.etsi.osl.tmf.common.model.ELifecycle; +import org.etsi.osl.tmf.common.model.EValueType; +import org.etsi.osl.tmf.rcm634.model.ResourceSpecificationCreate; +import org.etsi.osl.tmf.ri639.model.Characteristic; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import com.fasterxml.jackson.databind.JsonNode; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps; +import io.fabric8.kubernetes.client.utils.Serialization; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Maps TMF639 Resources to Kubernetes-style Custom Resources. + * + * Conversion: + * - TMF639 Resource → CustomResource spec field + * - ResourceCharacteristics → spec properties + * - Resource ID → metadata.name (sanitized) + * - Adds labels for tracking (tmf.resource.id, giter.role) + * + * @author ctranoris + */ +@Component +@Slf4j +public class ResourceMapper { + + + + + @Value("${osl-controller.category}") + private String compcategory; + + private String resourceCategory; + + private final ProducerProperties producerProperties; + + public ResourceMapper(ProducerProperties producerProperties) { + this.producerProperties = producerProperties; + } + + private String getResourceCategory() { + CustomResourceDefinition exchangedCRD = producerProperties.getExchangedCRD(); + + if ( exchangedCRD!=null ) { + this.resourceCategory = exchangedCRD.getSpec().getNames().getKind() + "." + exchangedCRD.getSpec().getGroup() + "." + compcategory; + } + + return this.resourceCategory; + } + + + + public List KubernetesCRD2OpensliceCRD(CustomResourceDefinition crd) { + + ArrayList result = new ArrayList<>(); + crd.getSpec().getVersions().stream().forEach(version -> { + KubernetesCRDV1 kcrd = KubernetesCRDV1.builder() + .name( crd.getSpec().getNames().getKind() + + "@" + + crd.getSpec().getGroup()+"/"+version.getName() ) + .version( version.getName() ) + .currentContextCluster( "" ) + .clusterMasterURL( "" ) + .fullResourceName( crd.getFullResourceName() ) + .kind( crd.getSpec().getNames().getKind() ) + .apiGroup( crd.getSpec().getGroup() ) + .uID( crd.getMetadata().getUid() ) + .description( version.getSchema().getOpenAPIV3Schema().getDescription() ) + .metadata( "" ) + .build(); + + if (version.getSchema().getOpenAPIV3Schema().getProperties() != null) + version.getSchema().getOpenAPIV3Schema().getProperties().forEach((kPropName, vProVal) -> { + if (kPropName.equals("spec") || kPropName.equals("status")) { + log.debug("propName={} propValue={} ", kPropName, vProVal.getType()); + addCRDProperties(kcrd, kPropName, vProVal, ""); + } + + + }); + + if (version.getSchema().getOpenAPIV3Schema().getAdditionalProperties() != null) { + version.getSchema().getOpenAPIV3Schema().getAdditionalProperties().getAdditionalProperties() + .forEach((kPropName, vProVal) -> { + log.debug("propName={} propValue={} ", kPropName, vProVal); + KubernetesCRDProperty kpcrdProperty = KubernetesCRDProperty + .builder() + .name(kPropName) + .build(); + kcrd.getAdditionalProperties().put("additionalProperty." + kPropName, kpcrdProperty); + + }); + } + + kcrd.setYaml( Serialization.asYaml( version.getSchema().getOpenAPIV3Schema().getProperties() ) ); + kcrd.setJson( Serialization.asJson( version.getSchema().getOpenAPIV3Schema().getProperties() ) ); + + result.add( kcrd ); + }); + + return result; + + } + + private void addCRDProperties(KubernetesCRDV1 kcrd, String kPropName, JSONSchemaProps vProVal, String prefix ) { + + + String propertyToAdd = prefix + kPropName; + + EValueType etype; + if (vProVal.getType() == null) { + etype = EValueType.TEXT; + } else if (vProVal.getType().equalsIgnoreCase("boolean")) { + etype = EValueType.BOOLEAN; + } else if (vProVal.getType().equalsIgnoreCase("integer")) { + etype = EValueType.INTEGER; + } else if (vProVal.getType().equalsIgnoreCase("object")) { + etype = EValueType.OBJECT; + vProVal.getProperties().forEach((pk, pv) -> { + addCRDProperties(kcrd, pk, pv, propertyToAdd + "."); + }); + } else if (vProVal.getType().equalsIgnoreCase("array")) { + etype = EValueType.ARRAY; + vProVal.getProperties().forEach((pk, pv) -> { + addCRDProperties(kcrd, pk, pv, propertyToAdd + "[]."); + }); + } else + etype = EValueType.TEXT; + + + KubernetesCRDProperty kpcrdProperty = KubernetesCRDProperty + .builder() + .name(kPropName) + .description( vProVal.getDescription() ) + .defaultValue( vProVal.getDefault()!=null? vProVal.getDefault().asText() : "" ) + .valueType( etype.getValue() ) + .build(); + + + kcrd.getProperties().put( propertyToAdd, kpcrdProperty ); + + } + + public ResourceSpecificationCreate toRSpecCreate( KubernetesCRDV1 crdv1) { + + ResourceSpecificationCreate rsc = new ResourceSpecificationCreate(); + + rsc.setName( crdv1.getKind() + "." + crdv1.getApiGroup() ); + rsc.setCategory( this.getResourceCategory() ); + rsc.setDescription( "Specification for " + rsc.getName() + ", version "+ crdv1.getVersion() ); + rsc.setVersion( crdv1.getVersion() ); + rsc.setType( crdv1.getKind() + "." + crdv1.getApiGroup() ); + + rsc.setLifecycleStatus( ELifecycle.ACTIVE.getValue() ); + rsc.addResourceSpecificationCharacteristicItemShort("fullResourceName", crdv1.getFullResourceName(), EValueType.TEXT.getValue(), "", false); + rsc.addResourceSpecificationCharacteristicItemShort("Kind", crdv1.getKind(), EValueType.TEXT.getValue(), "", false); + rsc.addResourceSpecificationCharacteristicItemShort("apiGroup", crdv1.getApiGroup(), EValueType.TEXT.getValue(), "", false); + rsc.addResourceSpecificationCharacteristicItemShort("metadata", crdv1.getMetadata(), EValueType.TEXT.getValue(), "", false); + rsc.addResourceSpecificationCharacteristicItemShort("yaml", crdv1.getYaml(), EValueType.TEXT.getValue(), "", false); + rsc.addResourceSpecificationCharacteristicItemShort("json", crdv1.getJson(), EValueType.TEXT.getValue(), "", false); + + rsc.addResourceSpecificationCharacteristicItemShort( "_GITER_SPEC", "", EValueType.TEXT.getValue(), "Used for providing the json Custom Resource description to apply", true); + +// rsc.addResourceSpecificationCharacteristicItemShort( "properties", "", EValueType.SET.getValue()); +// rsc.addResourceSpecificationCharacteristicItemShort( "additionalProperties", "", EValueType.SET.getValue()); + if (crdv1.getProperties() != null) + crdv1.getProperties().forEach((kPropName, vProVal) -> { + + EValueType etype; + if ( vProVal.getValueType().equalsIgnoreCase("boolean")) { + etype = EValueType.BOOLEAN; + } else if ( vProVal.getValueType().equalsIgnoreCase("integer")) { + etype = EValueType.INTEGER; + } else if ( vProVal.getValueType().equalsIgnoreCase("object")) { + etype = EValueType.OBJECT; + } else + etype = EValueType.TEXT; + + rsc.addResourceSpecificationCharacteristicItemShort(kPropName , vProVal.getDefaultValue(), etype.getValue(), vProVal.getDescription(), true); + + }); + + if (crdv1.getAdditionalProperties() != null ) { + crdv1.getAdditionalProperties().forEach((kPropName, vProVal) -> { + rsc.addResourceSpecificationCharacteristicItemShort("additionalProperty." + kPropName, vProVal.getDefaultValue(), EValueType.TEXT.getValue(), vProVal.getDescription(), true); + + }); + } + + return rsc; + } + + + +// public ResourceSpecificationCreate crdSchematoRSpecCreate() { +// +// +// CustomResourceDefinition exchangedCRD = producerProperties.getExchangedCRD(); +// +// ResourceSpecificationCreate rsc = new ResourceSpecificationCreate(); +// rsc.setName( exchangedCRD.getSpec().getNames().getKind() + "." + exchangedCRD.getSpec().getGroup() ); +// rsc.setCategory( this.getResourceCategory() ); +// rsc.setDescription( "Specification for " + rsc.getName() ); +// +// rsc.setType( rsc.getName() ); +// +// +// exchangedCRD.getSpec().getVersions().stream().forEach(version -> { +// +// rsc.setVersion( version.getName() ); +// +// if (version.getSchema().getOpenAPIV3Schema().getProperties() != null) +// version.getSchema().getOpenAPIV3Schema().getProperties().forEach((kPropName, vProVal) -> { +// if (kPropName.equals("spec") || kPropName.equals("status")) { +// log.debug("propName={} propValue={} ", kPropName, vProVal.getType()); +// addCRDProperties(rsc, kPropName, vProVal, ""); +// } +// +// +// }); +// +// if (version.getSchema().getOpenAPIV3Schema().getAdditionalProperties() != null) { +// version.getSchema().getOpenAPIV3Schema().getAdditionalProperties().getAdditionalProperties() +// .forEach((kPropName, vProVal) -> { +// log.debug("propName={} propValue={} ", kPropName, vProVal); +// exchangedCRD.getAdditionalProperties().put("additionalProperty." + kPropName, rsc); +// +// +// }); +// } +// +// rsc.setLifecycleStatus( ELifecycle.ACTIVE.getValue() ); +// +// }); +// +// +// return rsc; +// } +// +// +// private void addCRDProperties(ResourceSpecificationCreate rsc, String kPropName, JSONSchemaProps vProVal, String prefix ) { +// +// +// String propertyToAdd = prefix + kPropName; +// +// EValueType etype; +// if (vProVal.getType() == null) { +// etype = EValueType.TEXT; +// } else if (vProVal.getType().equalsIgnoreCase("boolean")) { +// etype = EValueType.BOOLEAN; +// } else if (vProVal.getType().equalsIgnoreCase("integer")) { +// etype = EValueType.INTEGER; +// } else if (vProVal.getType().equalsIgnoreCase("object")) { +// etype = EValueType.OBJECT; +// vProVal.getProperties().forEach((pk, pv) -> { +// addCRDProperties(rsc, pk, pv, propertyToAdd + "."); +// }); +// } else if (vProVal.getType().equalsIgnoreCase("array")) { +// etype = EValueType.ARRAY; +// vProVal.getProperties().forEach((pk, pv) -> { +// addCRDProperties(rsc, pk, pv, propertyToAdd + "[]."); +// }); +// } else +// etype = EValueType.TEXT; +// +// +// rsc.addResourceSpecificationCharacteristicItemShort(kPropName , +// vProVal.getDefault()!=null? vProVal.getDefault().asText() : "", +// etype.getValue(), vProVal.getDescription(), false); +// +// +// } + + +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/service/ResourceRepoService.java b/src/main/java/org/etsi/osl/controllers/giter/service/ResourceRepoService.java new file mode 100644 index 0000000000000000000000000000000000000000..9951efcceb1271c5e78014c8d6a7442b8c5a0d89 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/service/ResourceRepoService.java @@ -0,0 +1,313 @@ +package org.etsi.osl.controllers.giter.service; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.Date; +import java.util.Map; +import java.util.UUID; +import org.etsi.osl.controllers.giter.adapter.git.GitAdapter; +import org.etsi.osl.controllers.giter.api.CatalogClient; +import org.etsi.osl.controllers.giter.exception.SchemaValidationException; +import org.etsi.osl.controllers.giter.model.CustomResource; +import org.etsi.osl.controllers.giter.model.ReconciliationTask; +import org.etsi.osl.tmf.common.model.EValueType; +import org.etsi.osl.tmf.common.model.service.Note; +import org.etsi.osl.tmf.ri639.model.Characteristic; +import org.etsi.osl.tmf.ri639.model.Resource; +import org.etsi.osl.tmf.ri639.model.ResourceCreate; +import org.etsi.osl.tmf.ri639.model.ResourceStatusType; +import org.etsi.osl.tmf.ri639.model.ResourceUpdate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.client.utils.Serialization; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class ResourceRepoService { + + @Autowired + CatalogClient aCatalogClient; + + @Autowired + private ResourceMapper resourceMapper; + + @Autowired + ProducerService producerService; + + @Autowired + SimpleResourceMapper simpleResourceMapper; + + @Autowired + private SchemaValidator schemaValidator; + + @Autowired + private GitAdapter gitAdapter; + + @Autowired + private StatusWatcherService statusWatcherService; + + /** + * Enumeration for operation types + */ + private enum OperationType { + CREATE, UPDATE + } + + public Resource createResource( Map headers, ResourceCreate resourceRequested) { + log.info("Processing CREATE operation - Resource name: {}", resourceRequested.getName()); + + String resourceid = extractResourceId(headers); + ResourceUpdate resourceUpdate = simpleResourceMapper.resourceCreateToResourceUpdate(resourceRequested); + + return processCreateOrUpdate(OperationType.CREATE, resourceid, resourceUpdate); + } + + public Resource updateResource( Map headers, ResourceUpdate r) { + log.info("Processing UPDATE operation"); + + String resourceid = extractResourceId(headers); + + return processCreateOrUpdate(OperationType.UPDATE, resourceid, r); + } + + /** + * Extract resource ID from headers + */ + private String extractResourceId(Map headers) { + if (headers.get("org.etsi.osl.resourceId") != null) { + return (String) headers.get("org.etsi.osl.resourceId"); + } + return ""; + } + + /** + * Common logic for CREATE and UPDATE operations + */ + private Resource processCreateOrUpdate(OperationType operationType, String resourceid, ResourceUpdate resourceUpdate) { + GenericKubernetesResource cr = null; + String crFilePath = null; + + Note noteItem = new Note(); + StringBuilder messages = new StringBuilder(); + + // Extract _GITER_SPEC characteristic and make it a KubernetesResource + cr = extractCustomResourceFromCharacteristics(resourceUpdate, messages, operationType); + + if (cr != null) { + try { + // Step 1: Validate Custom Resource against CRD schema + validateCustomResource(cr, messages, operationType); + + // Step 2: Write Custom Resource to Git repository (filename: name-resourceid.yaml) + crFilePath = gitAdapter.writeCustomResource(cr, resourceid); + + String s = String.format("Custom Resource %s in Git - Path: %s", + operationType == OperationType.CREATE ? "written" : "updated", crFilePath); + messages.append(s); + log.info(s); + + // Step 3: Commit changes + String commitMessage = String.format("[%s] %s %s-%s", + operationType, cr.getKind(), cr.getMetadata().getName(), resourceid); + String commitId = gitAdapter.commit(commitMessage, resourceid); + + s = String.format(" Committed - CommitId: %s", commitId); + messages.append(s); + log.info(s); + + // Step 4: Push to remote + gitAdapter.push(); + log.info("Changes pushed to remote"); + messages.append(" Pushed to remote."); + + // Step 5: Handle reconciliation + handleReconciliation(operationType, resourceid, cr, crFilePath, messages); + + s = String.format(" %s operation completed successfully", operationType); + messages.append(s); + log.info(s); + + } catch (SchemaValidationException e) { + log.error("{} operation failed", operationType, e); + e.printStackTrace(); + } catch (IOException e) { + log.error("{} operation failed", operationType, e); + e.printStackTrace(); + } + } + + noteItem.author("GITER"); + noteItem.setText(messages.toString()); + noteItem.setDate(OffsetDateTime.now(ZoneOffset.UTC).toString()); + resourceUpdate.addNoteItem(noteItem); + + // Send it to TMF API + Resource res = aCatalogClient.updateResourceById(resourceid, resourceUpdate); + + return res; + } + + /** + * Extract Custom Resource from _GITER_SPEC characteristic + */ + private GenericKubernetesResource extractCustomResourceFromCharacteristics( + ResourceUpdate resourceUpdate, StringBuilder messages, OperationType operationType) { + + if (resourceUpdate.getResourceCharacteristic() == null) { + return null; + } + + for (Characteristic c : resourceUpdate.getResourceCharacteristic()) { + if (c.getName().equals("_GITER_SPEC")) { + if (c.getValue() != null && c.getValue().getValue() != null) { + try { + GenericKubernetesResource cr = Serialization.unmarshal(c.getValue().getValue()); + resourceUpdate.addResourceCharacteristicItemShort("_GITER_SPEC_APPLIED", + c.getValue().getValue(), EValueType.TEXT.getValue()); + return cr; + } catch (Exception e) { + log.error("{} Serialization operation failed", operationType, e); + messages.append(String.format("Serialization failed for %s: %s ", operationType, e.getMessage())); + e.printStackTrace(); + } + } + } + } + return null; + } + + /** + * Validate Custom Resource against CRD schema + */ + private void validateCustomResource(GenericKubernetesResource cr, StringBuilder messages, + OperationType operationType) throws SchemaValidationException { + SchemaValidator.ValidationResult validation = schemaValidator.validate(cr); + + if (validation.isFailure()) { + log.error("Validation failed for {} Errors: {}", operationType, validation.getErrorMessage()); + messages.append(String.format("Validation failed for %s Errors: %s ", operationType, validation.getErrorMessage())); + throw new SchemaValidationException( + "Schema validation failed for " + operationType, + cr.getMetadata().getName(), + Collections.singletonList(validation.getErrorMessage()) + ); + } + } + + /** + * Handle reconciliation based on operation type + */ + private void handleReconciliation(OperationType operationType, String resourceid, + GenericKubernetesResource cr, String crFilePath, StringBuilder messages) { + if (resourceid.isEmpty() || crFilePath == null) { + return; + } + + if (operationType == OperationType.CREATE) { + // Always start reconciliation for CREATE + ReconciliationTask task = statusWatcherService.startReconciliation(resourceid, cr, crFilePath); + String s = String.format(" Reconciliation started - TaskId: %s", task.getTaskId()); + messages.append(s); + log.info(s); + } else { + // For UPDATE, only start if not already active + String existingCrPath = statusWatcherService.getCrFilePathForResource(resourceid); + if (existingCrPath == null) { + ReconciliationTask task = statusWatcherService.startReconciliation(resourceid, cr, crFilePath); + String s = String.format(" Reconciliation started - TaskId: %s", task.getTaskId()); + messages.append(s); + log.info(s); + } + } + } + + public Resource deleteResource( Map headers, ResourceUpdate r) { + String resourceid = ""; + + if ( headers.get("org.etsi.osl.serviceId") !=null ) { + + } + if ( headers.get("org.etsi.osl.resourceId") !=null ) { //the resource to update back + resourceid = (String) headers.get("org.etsi.osl.resourceId") ; + } + if ( headers.get("org.etsi.osl.serviceOrderId") !=null ) { + + } + + ResourceUpdate resourceUpdate = r; + StringBuilder messages = new StringBuilder(); + Note noteItem = new Note(); + + // Get CR file path from reconciliation task before stopping it + String crFilePath = null; + if (!resourceid.isEmpty()) { + crFilePath = statusWatcherService.getCrFilePathForResource(resourceid); + + // Stop reconciliation task for this resource + boolean stopped = statusWatcherService.stopReconciliation(resourceid); + if (stopped) { + log.info("Stopped reconciliation task for deleted resource: {}", resourceid); + messages.append("Reconciliation stopped. "); + } + } + + // Delete CR from Git and move to archive + if (crFilePath != null && !crFilePath.isEmpty()) { + try { + boolean deleted = gitAdapter.deleteCustomResourceByPath(crFilePath); + if (deleted) { + String s = String.format("CR archived from Git - Path: %s", crFilePath); + messages.append(s); + log.info(s); + + // Commit the deletion + String commitMessage = String.format("[DELETE] %s", crFilePath); + String commitId = gitAdapter.commit(commitMessage, resourceid); + + s = String.format(" Committed - CommitId: %s", commitId); + messages.append(s); + log.info(s); + + // Push to remote + gitAdapter.push(); + messages.append(" Pushed to remote."); + log.info("Deletion pushed to remote"); + } else { + messages.append("CR file not found in Git repository. "); + log.warn("CR file not found for deletion: {}", crFilePath); + } + } catch (IOException e) { + log.error("Failed to delete CR from Git", e); + messages.append("Failed to delete CR from Git: " + e.getMessage() + " "); + } + } else { + messages.append("No CR file path found for resource. "); + log.warn("No CR file path found for resource: {}", resourceid); + } + + resourceUpdate.setResourceStatus(ResourceStatusType.UNKNOWN); + + resourceUpdate.addResourceCharacteristicItemShort("status.infoMessage", "Resource deleted", EValueType.TEXT.getValue()); + resourceUpdate.addResourceCharacteristicItemShort("status.Health", "Deleted " + new Date(), EValueType.TEXT.getValue()); + + noteItem.author("GITER"); + noteItem.setText(messages.toString()); + noteItem.setDate(OffsetDateTime.now(ZoneOffset.UTC).toString()); + resourceUpdate.addNoteItem(noteItem); + + Resource res = aCatalogClient.updateResourceById( resourceid, resourceUpdate); + + return res; + } + + + + + +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/service/SchemaValidator.java b/src/main/java/org/etsi/osl/controllers/giter/service/SchemaValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..e3921c623e58c34c99f0583c0cc9b68d2aad366b --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/service/SchemaValidator.java @@ -0,0 +1,179 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import lombok.extern.slf4j.Slf4j; +import org.etsi.osl.controllers.giter.model.CustomResource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Validates Custom Resources against CRD schema. + * + * Uses JSON Schema validator to check: + * - Required fields present + * - Field types correct + * - Value constraints met (min/max, enum values, etc.) + * + * @author ctranoris + */ +@Component +@Slf4j +public class SchemaValidator { + + private JsonNode crdSchema; + + private final ObjectMapper objectMapper; + + private JsonSchema specValidator; + + public SchemaValidator( + ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + + if (crdSchema != null) { + initializeValidator(); + } else { + log.warn("CRD schema not loaded. Schema validation will be skipped."); + } + } + + /** + * Validate a Custom Resource against the CRD schema. + * + * @param cr The Custom Resource to validate + * @return ValidationResult with success/failure and error messages + */ + public ValidationResult validate(GenericKubernetesResource cr) { + if (specValidator == null) { + log.warn("Schema validator not initialized. Skipping validation for: {}", + cr.getMetadata().getName()); + return ValidationResult.success(); + } + + log.debug("Validating Custom Resource: {}", cr.getMetadata().getName()); + + try { + // Convert CustomResource to JsonNode + JsonNode crNode = objectMapper.valueToTree(cr); + + // Extract spec field for validation + JsonNode specNode = crNode.get("spec"); + if (specNode == null) { + return ValidationResult.failure("Missing 'spec' field in Custom Resource"); + } + + // Validate spec against schema + Set errors = specValidator.validate(specNode); + + if (errors.isEmpty()) { + log.debug("Validation passed for Custom Resource: {}", cr.getMetadata().getName()); + return ValidationResult.success(); + } else { + String errorMessages = errors.stream() + .map(ValidationMessage::getMessage) + .collect(Collectors.joining("; ")); + + log.warn("Validation failed for Custom Resource: {} - Errors: {}", + cr.getMetadata().getName(), errorMessages); + + return ValidationResult.failure(errorMessages); + } + + } catch (Exception e) { + log.error("Exception during validation", e); + return ValidationResult.failure("Validation exception: " + e.getMessage()); + } + } + + /** + * Initialize the JSON Schema validator from CRD schema. + */ + private void initializeValidator() { + try { + log.info("Initializing schema validator from CRD"); + + // Extract the spec validation schema from CRD + JsonNode validationSchema = crdSchema.at("/spec/validation/openAPIV3Schema/properties/spec"); + + if (validationSchema.isMissingNode()) { + log.warn("No spec validation schema found in CRD. Using permissive validation."); + return; + } + + // Create JSON Schema validator + JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7); + specValidator = factory.getSchema(validationSchema); + + log.info("Schema validator initialized successfully"); + + } catch (Exception e) { + log.error("Failed to initialize schema validator", e); + } + } + + /** + * Validation result with success/failure and error messages. + */ + public static class ValidationResult { + private final boolean success; + private final String errorMessage; + + private ValidationResult(boolean success, String errorMessage) { + this.success = success; + this.errorMessage = errorMessage; + } + + public static ValidationResult success() { + return new ValidationResult(true, null); + } + + public static ValidationResult failure(String errorMessage) { + return new ValidationResult(false, errorMessage); + } + + public boolean isSuccess() { + return success; + } + + public boolean isFailure() { + return !success; + } + + public String getErrorMessage() { + return errorMessage; + } + + @Override + public String toString() { + return success ? "Validation successful" : "Validation failed: " + errorMessage; + } + } +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/service/SimpleResourceMapper.java b/src/main/java/org/etsi/osl/controllers/giter/service/SimpleResourceMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..e1360839248868a043ec519c2f1873a4f1e4712b --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/service/SimpleResourceMapper.java @@ -0,0 +1,14 @@ +package org.etsi.osl.controllers.giter.service; + +import org.etsi.osl.tmf.ri639.model.Resource; +import org.etsi.osl.tmf.ri639.model.ResourceCreate; +import org.etsi.osl.tmf.ri639.model.ResourceUpdate; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface SimpleResourceMapper { + + ResourceUpdate resourceToResourceUpdate(Resource source); + ResourceUpdate resourceCreateToResourceUpdate(ResourceCreate source); + +} diff --git a/src/main/java/org/etsi/osl/controllers/giter/service/StatusWatcherService.java b/src/main/java/org/etsi/osl/controllers/giter/service/StatusWatcherService.java new file mode 100644 index 0000000000000000000000000000000000000000..3c34c3525df1e3f75873e343d357115a2b575e90 --- /dev/null +++ b/src/main/java/org/etsi/osl/controllers/giter/service/StatusWatcherService.java @@ -0,0 +1,649 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.jayway.jsonpath.JsonPath; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import lombok.extern.slf4j.Slf4j; +import org.etsi.osl.controllers.giter.adapter.git.GitAdapter; +import org.etsi.osl.controllers.giter.api.CatalogClient; +import org.etsi.osl.controllers.giter.config.ProducerProperties; +import org.etsi.osl.controllers.giter.config.ProducerProperties.StatusMapping; +import org.etsi.osl.controllers.giter.model.ReconciliationTask; +import org.etsi.osl.controllers.giter.model.ReconciliationTask.ReconciliationState; +import org.etsi.osl.tmf.common.model.EValueType; +import org.etsi.osl.tmf.common.model.service.Note; +import org.etsi.osl.tmf.ri639.model.ResourceOperationalStateType; +import org.etsi.osl.tmf.ri639.model.ResourceStatusType; +import org.etsi.osl.tmf.ri639.model.ResourceUpdate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Service responsible for monitoring CR status changes after commits. + * + * Key responsibilities: + * 1. Track active reconciliation tasks + * 2. Poll Git repository for status changes + * 3. Detect status field changes in CRs + * 4. Send updates to TMF repository when status changes + * 5. Persist tasks to disk for restart recovery + * + * @author ctranoris + */ +@Service +@Slf4j +public class StatusWatcherService { + + @Autowired + private ProducerProperties producerProperties; + + @Autowired + private GitAdapter gitAdapter; + + @Autowired + private CatalogClient catalogClient; + + private final ConcurrentHashMap activeTasks = new ConcurrentHashMap<>(); + private final ObjectMapper jsonMapper; + private final ObjectMapper yamlMapper; + private Path persistencePath; + + public StatusWatcherService() { + this.jsonMapper = new ObjectMapper(); + this.jsonMapper.registerModule(new JavaTimeModule()); + + this.yamlMapper = new ObjectMapper(new YAMLFactory()); + this.yamlMapper.registerModule(new JavaTimeModule()); + } + + @PostConstruct + public void initialize() { + log.info("Initializing StatusWatcherService"); + + // Set up persistence directory + String configuredPath = producerProperties.getStatusWatcher().getPersistencePath(); + if (configuredPath != null && !configuredPath.isEmpty()) { + // Use configured persistence path + persistencePath = Paths.get(configuredPath, "reconciliation-tasks.json"); + log.info("Using configured persistence path: {}", configuredPath); + } else { + // Default to .giter inside git local path + String localPath = producerProperties.getGit().getLocalPath(); + persistencePath = Paths.get(localPath, ".giter", "reconciliation-tasks.json"); + log.info("Using default persistence path inside git repo: {}", persistencePath.getParent()); + } + + try { + Files.createDirectories(persistencePath.getParent()); + log.info("Persistence directory created: {}", persistencePath.getParent()); + } catch (IOException e) { + log.error("Failed to create persistence directory", e); + } + + // Load persisted tasks from previous run + loadPersistedTasks(); + + log.info("StatusWatcherService initialized with {} active tasks", activeTasks.size()); + } + + /** + * Start reconciliation for a newly committed CR. + * + * @param tmfResourceId The TMF639 Resource ID to update + * @param cr The Custom Resource that was committed + * @param crFilePath Path to the CR file in Git + * @return The created reconciliation task + */ + public ReconciliationTask startReconciliation(String tmfResourceId, GenericKubernetesResource cr, String crFilePath) { + log.info("Starting reconciliation for TMF Resource: {}, CR: {}", tmfResourceId, cr.getMetadata().getName()); + + long timeoutMs = producerProperties.getStatusWatcher().getTimeoutMs(); + + ReconciliationTask task = ReconciliationTask.builder() + .taskId(UUID.randomUUID().toString()) + .tmfResourceId(tmfResourceId) + .apiVersion(cr.getApiVersion()) + .kind(cr.getKind()) + .crName(cr.getMetadata().getName()) + .crFilePath(crFilePath) + .lastKnownStatus(extractStatus(cr)) + .startedAt(Instant.now()) + .timeoutAt(Instant.now().plusMillis(timeoutMs)) + .state(ReconciliationState.ACTIVE) + .build(); + + activeTasks.put(task.getTaskId(), task); + persistTasks(); + + log.info("Reconciliation task created - TaskId: {}, Timeout: {}ms", task.getTaskId(), timeoutMs); + return task; + } + + /** + * Scheduled task to poll all active reconciliation tasks. + * Runs at fixed rate based on poll interval configuration. + */ + @Scheduled(fixedDelayString = "${producer.status-watcher.poll-interval-ms:10000}") + public void pollActiveTasks() { + if (activeTasks.isEmpty()) { + return; + } + + // Check if GitAdapter is initialized before polling + if (!gitAdapter.isReady()) { + log.debug("GitAdapter not ready yet, skipping poll cycle"); + return; + } + + log.debug("Polling {} active reconciliation tasks", activeTasks.size()); + long pollIntervalMs = producerProperties.getStatusWatcher().getPollIntervalMs(); + + for (ReconciliationTask task : activeTasks.values()) { + if (!task.isActive()) { + continue; + } + + // Tasks no longer timeout - they poll indefinitely until deleted + // Reset timeout on each poll to keep task active + long timeoutMs = producerProperties.getStatusWatcher().getTimeoutMs(); + task.setTimeoutAt(Instant.now().plusMillis(timeoutMs)); + + // Check if enough time has passed since last poll + if (!task.shouldPoll(pollIntervalMs)) { + continue; + } + + try { + pollTaskForStatusChange(task); + } catch (Exception e) { + log.error("Error polling task {}: {}", task.getTaskId(), e.getMessage(), e); + // Don't mark as failed, just log and continue polling + log.warn("Will retry task {} on next poll cycle", task.getTaskId()); + } + } + + // Clean up completed/stopped tasks and persist + cleanupCompletedTasks(); + } + + /** + * Poll a single task for status changes in Git. + */ + private void pollTaskForStatusChange(ReconciliationTask task) throws IOException { + log.debug("Polling task {} for CR {}", task.getTaskId(), task.getCrName()); + + // Pull latest changes from Git + gitAdapter.pull(); + + // Read the CR file from Git + Path crPath = Paths.get(producerProperties.getGit().getLocalPath(), task.getCrFilePath()); + + if (!Files.exists(crPath)) { + log.warn("CR file not found: {}", crPath); + task.setLastPollAt(Instant.now()); + task.setPollCount(task.getPollCount() + 1); + return; + } + + GenericKubernetesResource currentCr = yamlMapper.readValue(crPath.toFile(), GenericKubernetesResource.class); + Map currentStatus = extractStatus(currentCr); + + task.setLastPollAt(Instant.now()); + task.setPollCount(task.getPollCount() + 1); + + // Check if status has changed + if (hasStatusChanged(task.getLastKnownStatus(), currentStatus)) { + log.info("Status change detected for task {} - CR: {}", task.getTaskId(), task.getCrName()); + handleStatusChange(task, currentCr, currentStatus); + } else { + log.debug("No status change for task {} (poll #{})", task.getTaskId(), task.getPollCount()); + } + + persistTasks(); + } + + /** + * Extract status map from CR. + */ + @SuppressWarnings("unchecked") + private Map extractStatus(GenericKubernetesResource cr) { + if (cr.getAdditionalProperties() != null && cr.getAdditionalProperties().containsKey("status")) { + Object status = cr.getAdditionalProperties().get("status"); + if (status instanceof Map) { + return (Map) status; + } + } + return null; + } + + /** + * Check if status has changed between two states. + */ + private boolean hasStatusChanged(Map oldStatus, Map newStatus) { + // Both null = no change + if (oldStatus == null && newStatus == null) { + return false; + } + + // One is null, other is not = change + if (oldStatus == null || newStatus == null) { + return true; + } + + // Compare the maps + return !Objects.equals(oldStatus, newStatus); + } + + /** + * Handle detected status change - send update to TMF repository. + * Task remains ACTIVE to continue polling for future status changes, + * unless the status is SUSPENDED with DISABLE operational state (terminal state). + */ + private void handleStatusChange(ReconciliationTask task, GenericKubernetesResource cr, Map newStatus) { + log.info("Processing status change for TMF Resource: {}", task.getTmfResourceId()); + + try { + ResourceUpdate resourceUpdate = new ResourceUpdate(); + ResourceStatusType statusType = null; + ResourceOperationalStateType operationalState = null; + + // Add status fields as characteristics + if (newStatus != null) { + flattenStatusToCharacteristics(resourceUpdate, newStatus, "status"); + + // Map status.phase to ResourceStatusType and OperationalStateType if present + String statusFieldPath = producerProperties.getCrd().getStatusFieldPath(); + Object phaseValue = getValueByPath(newStatus, statusFieldPath.replace("status.", "")); + if (phaseValue != null) { + String statusString = phaseValue.toString(); + statusType = mapToResourceStatusType(statusString); + operationalState = mapToOperationalStateType(statusString); + + resourceUpdate.setResourceStatus(statusType); + resourceUpdate.setOperationalState(operationalState); + + log.info("Mapped CR status '{}' to ResourceStatusType: {}, OperationalStateType: {}", + statusString, statusType, operationalState); + } + } + + // Add reconciliation note + Note note = new Note(); + note.setAuthor("GITER"); + note.setText(String.format("Status reconciled from Git - Poll #%d, CR: %s", + task.getPollCount(), task.getCrName())); + note.setDate(OffsetDateTime.now(ZoneOffset.UTC).toString()); + resourceUpdate.addNoteItem(note); + + // Send update to TMF repository + catalogClient.updateResourceById(task.getTmfResourceId(), resourceUpdate); + + // Update last known status + task.setLastKnownStatus(newStatus); + + // Check if this is a terminal state (SUSPENDED + DISABLE) + // If so, stop reconciliation and mark as COMPLETED + if (statusType == ResourceStatusType.SUSPENDED && + operationalState == ResourceOperationalStateType.DISABLE) { + log.info("Terminal state detected (SUSPENDED/DISABLE) - stopping reconciliation for Resource: {}", + task.getTmfResourceId()); + task.setState(ReconciliationState.COMPLETED); + log.info("Reconciliation completed for TMF Resource: {}", task.getTmfResourceId()); + } else { + // Reset timeout to allow continued polling + long timeoutMs = producerProperties.getStatusWatcher().getTimeoutMs(); + task.setTimeoutAt(Instant.now().plusMillis(timeoutMs)); + log.info("Successfully sent status update to TMF for Resource: {}, continuing to poll", task.getTmfResourceId()); + } + + } catch (Exception e) { + log.error("Failed to send status update to TMF: {}", e.getMessage(), e); + // Don't mark as failed, just log the error and continue polling + log.warn("Will retry on next poll cycle"); + } + + persistTasks(); + } + + /** + * Flatten nested status map into Resource characteristics. + */ + private void flattenStatusToCharacteristics(ResourceUpdate resourceUpdate, Map statusMap, String prefix) { + for (Map.Entry entry : statusMap.entrySet()) { + String key = prefix + "." + entry.getKey(); + Object value = entry.getValue(); + + if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map nestedMap = (Map) value; + flattenStatusToCharacteristics(resourceUpdate, nestedMap, key); + } else if (value != null) { + resourceUpdate.addResourceCharacteristicItemShort(key, value.toString(), EValueType.TEXT.getValue()); + } + } + } + + /** + * Get value from nested map by dot-separated path. + */ + @SuppressWarnings("unchecked") + private Object getValueByPath(Map map, String path) { + String[] parts = path.split("\\."); + Object current = map; + + for (String part : parts) { + if (current instanceof Map) { + current = ((Map) current).get(part); + } else { + return null; + } + } + return current; + } + + /** + * Map status string to TMF ResourceStatusType using configured mappings. + */ + private ResourceStatusType mapToResourceStatusType(String status) { + java.util.List mappings = producerProperties.getStatusWatcher().getStatusMappings(); + + // Try exact match first + for (StatusMapping mapping : mappings) { + if (mapping.getCrStatus() != null && mapping.getCrStatus().equals(status)) { + String mappedType = mapping.getResourceStatusType(); + if (mappedType != null) { + try { + return ResourceStatusType.valueOf(mappedType.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid ResourceStatusType in mapping: {}", mappedType); + } + } + } + } + + // Try case-insensitive match + for (StatusMapping mapping : mappings) { + if (mapping.getCrStatus() != null && mapping.getCrStatus().equalsIgnoreCase(status)) { + String mappedType = mapping.getResourceStatusType(); + if (mappedType != null) { + try { + return ResourceStatusType.valueOf(mappedType.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid ResourceStatusType in mapping: {}", mappedType); + } + } + } + } + + // Use default + String defaultType = producerProperties.getStatusWatcher().getDefaultResourceStatusType(); + try { + return ResourceStatusType.valueOf(defaultType.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid default ResourceStatusType: {}, using UNKNOWN", defaultType); + return ResourceStatusType.UNKNOWN; + } + } + + /** + * Map status string to TMF ResourceOperationalStateType using configured mappings. + */ + private ResourceOperationalStateType mapToOperationalStateType(String status) { + java.util.List mappings = producerProperties.getStatusWatcher().getStatusMappings(); + + // Try exact match first + for (StatusMapping mapping : mappings) { + if (mapping.getCrStatus() != null && mapping.getCrStatus().equals(status)) { + String mappedType = mapping.getOperationalStateType(); + if (mappedType != null) { + try { + return ResourceOperationalStateType.valueOf(mappedType.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid ResourceOperationalStateType in mapping: {}", mappedType); + } + } + } + } + + // Try case-insensitive match + for (StatusMapping mapping : mappings) { + if (mapping.getCrStatus() != null && mapping.getCrStatus().equalsIgnoreCase(status)) { + String mappedType = mapping.getOperationalStateType(); + if (mappedType != null) { + try { + return ResourceOperationalStateType.valueOf(mappedType.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid ResourceOperationalStateType in mapping: {}", mappedType); + } + } + } + } + + // Use default + String defaultType = producerProperties.getStatusWatcher().getDefaultOperationalStateType(); + try { + return ResourceOperationalStateType.valueOf(defaultType.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid default ResourceOperationalStateType: {}, using DISABLE", defaultType); + return ResourceOperationalStateType.DISABLE; + } + } + + /** + * Handle task timeout. + */ + private void handleTimeout(ReconciliationTask task) { + log.warn("Reconciliation timeout for task {} - CR: {}, Polls: {}", + task.getTaskId(), task.getCrName(), task.getPollCount()); + + task.setState(ReconciliationState.TIMEOUT); + task.setErrorMessage("Timeout reached after " + task.getPollCount() + " polls"); + + // Send timeout notification to TMF + try { + ResourceUpdate resourceUpdate = new ResourceUpdate(); + resourceUpdate.setResourceStatus(ResourceStatusType.UNKNOWN); + + Note note = new Note(); + note.setAuthor("GITER"); + note.setText(String.format("Reconciliation timeout - No status update received after %d polls", + task.getPollCount())); + note.setDate(OffsetDateTime.now(ZoneOffset.UTC).toString()); + resourceUpdate.addNoteItem(note); + + catalogClient.updateResourceById(task.getTmfResourceId(), resourceUpdate); + } catch (Exception e) { + log.error("Failed to send timeout notification: {}", e.getMessage()); + } + + persistTasks(); + } + + /** + * Handle task error. + */ + private void handleError(ReconciliationTask task, String errorMessage) { + log.error("Reconciliation error for task {}: {}", task.getTaskId(), errorMessage); + task.setState(ReconciliationState.FAILED); + task.setErrorMessage(errorMessage); + persistTasks(); + } + + /** + * Clean up completed/failed/timed-out tasks after a retention period. + */ + private void cleanupCompletedTasks() { + // Keep completed tasks for 1 hour for debugging, then remove + Instant cutoff = Instant.now().minusSeconds(3600); + + activeTasks.entrySet().removeIf(entry -> { + ReconciliationTask task = entry.getValue(); + if (!task.isActive() && task.getLastPollAt() != null && task.getLastPollAt().isBefore(cutoff)) { + log.debug("Removing completed task: {}", task.getTaskId()); + return true; + } + return false; + }); + } + + /** + * Persist active tasks to disk for restart recovery. + */ + private void persistTasks() { + try { + jsonMapper.writeValue(persistencePath.toFile(), activeTasks); + log.debug("Persisted {} tasks to {}", activeTasks.size(), persistencePath); + } catch (IOException e) { + log.error("Failed to persist tasks: {}", e.getMessage(), e); + } + } + + /** + * Load persisted tasks from disk (called on service startup). + */ + @SuppressWarnings("unchecked") + private void loadPersistedTasks() { + if (!Files.exists(persistencePath)) { + log.info("No persisted tasks found at {}", persistencePath); + return; + } + + try { + Map loadedTasks = jsonMapper.readValue( + persistencePath.toFile(), + jsonMapper.getTypeFactory().constructMapType( + ConcurrentHashMap.class, String.class, ReconciliationTask.class)); + + // Resume only active tasks + for (ReconciliationTask task : loadedTasks.values()) { + if (task.isActive()) { + // Extend timeout for resumed tasks + long timeoutMs = producerProperties.getStatusWatcher().getTimeoutMs(); + task.setTimeoutAt(Instant.now().plusMillis(timeoutMs)); + activeTasks.put(task.getTaskId(), task); + log.info("Resumed reconciliation task: {} for CR: {}", task.getTaskId(), task.getCrName()); + } + } + + log.info("Loaded {} active tasks from persistence", activeTasks.size()); + } catch (IOException e) { + log.error("Failed to load persisted tasks: {}", e.getMessage(), e); + } + } + + /** + * Stop reconciliation for a specific TMF Resource (e.g., on DELETE operation). + * + * @param tmfResourceId The TMF Resource ID to stop watching + * @return true if a task was stopped, false if no task found + */ + public boolean stopReconciliation(String tmfResourceId) { + log.info("Stopping reconciliation for TMF Resource: {}", tmfResourceId); + + for (ReconciliationTask task : activeTasks.values()) { + if (task.getTmfResourceId().equals(tmfResourceId)) { + task.setState(ReconciliationState.COMPLETED); + log.info("Reconciliation stopped for task: {}, CR: {}", task.getTaskId(), task.getCrName()); + persistTasks(); + return true; + } + } + + log.warn("No active reconciliation task found for TMF Resource: {}", tmfResourceId); + return false; + } + + /** + * Stop reconciliation by CR file path. + * + * @param crFilePath The CR file path to stop watching + * @return true if a task was stopped, false if no task found + */ + public boolean stopReconciliationByCrPath(String crFilePath) { + log.info("Stopping reconciliation for CR path: {}", crFilePath); + + for (ReconciliationTask task : activeTasks.values()) { + if (task.getCrFilePath().equals(crFilePath)) { + task.setState(ReconciliationState.COMPLETED); + log.info("Reconciliation stopped for task: {}, CR: {}", task.getTaskId(), task.getCrName()); + persistTasks(); + return true; + } + } + + log.warn("No active reconciliation task found for CR path: {}", crFilePath); + return false; + } + + /** + * Get CR file path for a TMF Resource ID. + * + * @param tmfResourceId The TMF Resource ID + * @return The CR file path, or null if no task found + */ + public String getCrFilePathForResource(String tmfResourceId) { + for (ReconciliationTask task : activeTasks.values()) { + if (task.getTmfResourceId().equals(tmfResourceId)) { + return task.getCrFilePath(); + } + } + return null; + } + + /** + * Get current active task count. + */ + public int getActiveTaskCount() { + return (int) activeTasks.values().stream().filter(ReconciliationTask::isActive).count(); + } + + /** + * Get all active tasks (for monitoring/debugging). + */ + public Map getActiveTasks() { + return new ConcurrentHashMap<>(activeTasks); + } + + @PreDestroy + public void shutdown() { + log.info("Shutting down StatusWatcherService with {} active tasks", activeTasks.size()); + persistTasks(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000000000000000000000000000000000000..2ba85543d0cc981519af4f1cd9399c16a5f63cf3 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,148 @@ +osl-controller: + category: giter.osl.etsi.org + +# Producer Service Configuration +producer: + git: + repository-url: ${PRODUCER_GIT_REPO_URL:} + local-path: ${PRODUCER_GIT_LOCAL_PATH:./tmp/git-repo} + branch: ${PRODUCER_GIT_BRANCH:main} + credentials: + username: ${GIT_USERNAME:} + password: ${GIT_PASSWORD:} + api-key: ${GIT_API_KEY:} + + crd: + schema-file: ${PRODUCER_CRD_SCHEMA_FILE:classpath:crd-schema.yaml} + status-field-path: ${PRODUCER_CRD_STATUS_FIELD_PATH:status.state} + + status-watcher: + poll-interval-ms: ${PRODUCER_STATUS_WATCHER_POLL_INTERVAL:10000} + timeout-ms: ${PRODUCER_STATUS_WATCHER_TIMEOUT:30000} + persistence-path: ${PRODUCER_STATUS_WATCHER_PERSISTENCE_PATH:} + default-resource-status-type: ${PRODUCER_STATUS_WATCHER_DEFAULT_RESOURCE_STATUS_TYPE:UNKNOWN} + default-operational-state-type: ${PRODUCER_STATUS_WATCHER_DEFAULT_OPERATIONAL_STATE_TYPE:DISABLE} + status-mappings: + - cr-status: Running + resource-status-type: AVAILABLE + operational-state-type: ENABLE + - cr-status: READY + resource-status-type: AVAILABLE + operational-state-type: ENABLE + - cr-status: Active + resource-status-type: AVAILABLE + operational-state-type: ENABLE + - cr-status: Provisioning + resource-status-type: RESERVED + operational-state-type: DISABLE + - cr-status: Pending + resource-status-type: RESERVED + operational-state-type: DISABLE + - cr-status: RESERVED + resource-status-type: RESERVED + operational-state-type: ENABLE + - cr-status: STANDBY + resource-status-type: STANDBY + operational-state-type: ENABLE + - cr-status: Stopped + resource-status-type: SUSPENDED + operational-state-type: ENABLE + - cr-status: Failed + resource-status-type: SUSPENDED + operational-state-type: DISABLE + - cr-status: Error + resource-status-type: SUSPENDED + operational-state-type: DISABLE + - cr-status: Unknown + resource-status-type: UNKNOWN + operational-state-type: DISABLE + +server: + port: 0 + +spring: + config: + activate: + on-profile: "default" + application: + name: GiterController + description: "Giter controler" + + servlet: + multipart.max-file-size: 10MB + multipart.max-request-size: 10MB + activemq: + brokerUrl: tcp://localhost:61616?jms.watchTopicAdvisories=false + user: artemis + password: artemis + pool: + enabled: true + max-connections: 100 + packages: + trust-all: true + autoconfigure.exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + +logging: + level: + root: INFO + org.etsi.osl.controllers.giter.*: INFO + org.springframework: INFO + org.apache.camel: INFO + com.zaxxer.hikari: INFO + pattern: + console: "%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" + file: "%d %p %c{1.} [%t] %m%n" + + + + +oauthsign: + key: "EK97Y7Y9WPGG1MEG" + +#QUEUE MESSAGES +CATALOG_GET_SERVICEORDERS: "jms:queue:CATALOG.GET.SERVICEORDERS" +CATALOG_GET_SERVICEORDER_BY_ID: "jms:queue:CATALOG.GET.SERVICEORDER_BY_ID" +CATALOG_ADD_SERVICEORDER: "jms:queue:CATALOG.ADD.SERVICEORDER" +CATALOG_UPD_SERVICEORDER_BY_ID: "jms:queue:CATALOG.UPD.SERVICEORDER_BY_ID" +CATALOG_GET_SERVICESPEC_BY_ID: "jms:queue:CATALOG.GET.SERVICESPEC_BY_ID" +CATALOG_ADD_SERVICESPEC: "jms:queue:CATALOG.ADD.SERVICESPEC" +CATALOG_UPD_SERVICESPEC: "jms:queue:CATALOG.UPD.SERVICESPEC" +CATALOG_UPDADD_SERVICESPEC: "jms:queue:CATALOG.UPDADD.SERVICESPEC" + + +CATALOG_GET_INITIAL_SERVICEORDERS_IDS: "jms:queue:CATALOG.GET.INITIAL_SERVICEORDERS" +CATALOG_GET_SERVICEORDER_IDS_BY_STATE: "jms:queue:CATALOG.GET.ACKNOWLEDGED_SERVICEORDERS" +CATALOG_ADD_SERVICE: "jms:queue:CATALOG.ADD.SERVICE" +CATALOG_UPD_SERVICE: "jms:queue:CATALOG.UPD.SERVICE" +CATALOG_GET_SERVICE_BY_ID: "jms:queue:CATALOG.GET.SERVICE" +CATALOG_GET_SERVICE_BY_ORDERID: "jms:queue:CATALOG.GET.SERVICE_BY_ORDERID" +CATALOG_SERVICE_QUEUE_ITEMS_GET: "jms:queue:CATALOG.SERVICEQUEUEITEMS.GET" +CATALOG_SERVICE_QUEUE_ITEM_UPD: "jms:queue:CATALOG.SERVICEQUEUEITEM.UPDATE" +CATALOG_SERVICE_QUEUE_ITEM_DELETE: "jms:queue:CATALOG.SERVICEQUEUEITEM.DELETE" +CATALOG_SERVICES_TO_TERMINATE: "jms:queue:CATALOG.GET.SERVICETOTERMINATE" + +CATALOG_GET_EXTERNAL_SERVICE_PARTNERS: "jms:queue:CATALOG.GET.EXTERNALSERVICEPARTNERS" +CATALOG_UPD_EXTERNAL_SERVICESPEC: "jms:queue:CATALOG.UPD.EXTERNAL_SERVICESPEC" + + +#RESOURCES MESSAGES +CATALOG_ADD_RESOURCE: "jms:queue:CATALOG.ADD.RESOURCE" +CATALOG_UPD_RESOURCE: "jms:queue:CATALOG.UPD.RESOURCE" +CATALOG_UPDADD_RESOURCE: "jms:queue:CATALOG.UPDADD.RESOURCE" +CATALOG_GET_RESOURCE_BY_ID: "jms:queue:CATALOG.GET.RESOURCE" +CATALOG_ADD_RESOURCESPEC: "jms:queue:CATALOG.ADD.RESOURCESPEC" +CATALOG_UPD_RESOURCESPEC: "jms:queue:CATALOG.UPD.RESOURCESPEC" +CATALOG_UPDADD_RESOURCESPEC: "jms:queue:CATALOG.UPDADD.RESOURCESPEC" +CATALOG_GET_RESOURCESPEC_BY_ID: "jms:queue:CATALOG.GET.RESOURCESPEC_BY_ID" +CATALOG_GET_RESOURCESPEC_BY_NAME_CATEGORY: "jms:queue:CATALOG.GET.RESOURCESPEC_BY_NAME_CATEGORY" + +#PARTNER MESSAGES +CATALOG_GET_PARTNER_ORGANIZATON_BY_ID: "jms:queue:CATALOG.GET.PARTNER_ORGANIZATION_BY_ID" +CATALOG_UPDATE_PARTNER_ORGANIZATION: "jms:queue:CATALOG.UPD.PARTNER_ORGANIZATION" +CATALOG_SERVICES_OF_PARTNERS: "jms:queue:CATALOG.GET.SERVICESOFPARTNERS" +CATALOG_RESOURCES_OF_PARTNERS: "jms:queue:CATALOG.GET.SERVICESOFPARTNERS" +EVENT_ORGANIZATION_CREATE: "jms:topic:EVENT.ORGANIZATION.CREATE" +EVENT_ORGANIZATION_CHANGED: "jms:topic:EVENT.ORGANIZATION.CHANGE" +--- + + diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000000000000000000000000000000000000..74229cd400fb31f76673214d76c0256f438e4ccc --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,11 @@ + ___ ____ _ _ + / _ \ _ __ ___ _ __ / ___|| (_) ___ ___ + | | | | '_ \ / _ \ '_ \\___ \| | |/ __/ _ \ + | |_| | |_) | __/ | | |___) | | | (_| __/ + \___/| .__/ \___|_| |_|____/|_|_|\___\___| + |_| + __ __________________ + / / __ __ / __/_ __/ __/ _/ + / _ \/ // / / _/ / / _\ \_/ / + /_.__/\_, / /___/ /_/ /___/___/ + /___/ \ No newline at end of file diff --git a/src/main/resources/crd-schema.yaml b/src/main/resources/crd-schema.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f107867e1ee3641df15d9e7bbabedba7bb205c3d --- /dev/null +++ b/src/main/resources/crd-schema.yaml @@ -0,0 +1,78 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: virtualmachines.compute.example.com +spec: + group: compute.example.com + scope: Namespaced + names: + plural: virtualmachines + singular: virtualmachine + kind: VirtualMachine + shortNames: + - vm + + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: + - cpu + - memory + - image + properties: + cpu: + type: integer + minimum: 1 + description: Number of virtual CPUs + memory: + type: string + pattern: "^[0-9]+(Mi|Gi)$" + description: RAM size (e.g., 512Mi, 4Gi) + image: + type: string + description: VM disk image reference (e.g., ubuntu-22.04) + powerState: + type: string + enum: ["Running", "Stopped"] + description: Desired power state of the VM + status: + type: object + properties: + state: + type: string + description: Current operational state of the VM + enum: ["Provisioning", "Running", "Stopped", "Failed", "Unknown", "Error"] + hostIP: + type: string + description: Host IP where the VM is running + vmIP: + type: string + description: Assigned IP address of the VM + message: + type: string + description: Human-readable status message + subresources: + status: {} + additionalPrinterColumns: + - name: CPU + type: integer + jsonPath: .spec.cpu + - name: Memory + type: string + jsonPath: .spec.memory + - name: Image + type: string + jsonPath: .spec.image + - name: Power + type: string + jsonPath: .spec.powerState + - name: State + type: string + jsonPath: .status.state diff --git a/src/test/java/org/etsi/osl/controllers/giter/adapter/git/JGitAdapterTest.java b/src/test/java/org/etsi/osl/controllers/giter/adapter/git/JGitAdapterTest.java new file mode 100644 index 0000000000000000000000000000000000000000..501cf5b5c5148a686de4ab73d9f7c81acedee998 --- /dev/null +++ b/src/test/java/org/etsi/osl/controllers/giter/adapter/git/JGitAdapterTest.java @@ -0,0 +1,281 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.adapter.git; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import org.eclipse.jgit.api.Git; +import org.etsi.osl.controllers.giter.config.ProducerProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for JGitAdapter. + */ +class JGitAdapterTest { + + @TempDir + Path tempDir; + + private JGitAdapter jGitAdapter; + private ProducerProperties producerProperties; + + @BeforeEach + void setUp() throws Exception { + producerProperties = new ProducerProperties(); + ProducerProperties.GitProperties gitProperties = new ProducerProperties.GitProperties(); + gitProperties.setLocalPath(tempDir.toString()); + gitProperties.setBranch("main"); + gitProperties.setRepositoryUrl(""); + + ProducerProperties.GitProperties.CredentialsProperties credentials = + new ProducerProperties.GitProperties.CredentialsProperties(); + gitProperties.setCredentials(credentials); + + producerProperties.setGit(gitProperties); + + jGitAdapter = new JGitAdapter(producerProperties); + + // Initialize a local git repo for testing + Git.init().setDirectory(tempDir.toFile()).call(); + } + + @Test + void writeCustomResource_shouldCreateFile() throws Exception { + // Given + initializeAdapter(); + GenericKubernetesResource cr = createTestCR("test-cron"); + + // When + String filePath = jGitAdapter.writeCustomResource(cr); + + // Then + assertNotNull(filePath); + assertTrue(filePath.contains("resources/stable.example.com/v1/CronTab/test-cron.yaml")); + assertTrue(Files.exists(tempDir.resolve(filePath))); + } + + @Test + void writeCustomResource_withResourceId_shouldIncludeInFilename() throws Exception { + // Given + initializeAdapter(); + GenericKubernetesResource cr = createTestCR("test-cron"); + + // When + String filePath = jGitAdapter.writeCustomResource(cr, "res-123"); + + // Then + assertNotNull(filePath); + assertTrue(filePath.contains("test-cron-res-123.yaml")); + assertTrue(Files.exists(tempDir.resolve(filePath))); + } + + @Test + void writeCustomResource_shouldCreateDirectories() throws Exception { + // Given + initializeAdapter(); + GenericKubernetesResource cr = createTestCR("test-cron"); + + // When + jGitAdapter.writeCustomResource(cr); + + // Then + Path expectedDir = tempDir.resolve("resources/stable.example.com/v1/CronTab"); + assertTrue(Files.isDirectory(expectedDir)); + } + + @Test + void writeCustomResource_shouldOverwriteExisting() throws Exception { + // Given + initializeAdapter(); + GenericKubernetesResource cr = createTestCR("test-cron"); + cr.setAdditionalProperty("spec", Map.of("schedule", "*/5 * * * *")); + + String filePath = jGitAdapter.writeCustomResource(cr, "res-123"); + long firstSize = Files.size(tempDir.resolve(filePath)); + + // Update CR with more data + cr.setAdditionalProperty("spec", Map.of("schedule", "*/10 * * * *", "image", "test-image")); + + // When + jGitAdapter.writeCustomResource(cr, "res-123"); + + // Then + long secondSize = Files.size(tempDir.resolve(filePath)); + assertNotEquals(firstSize, secondSize); + } + + @Test + void writeCustomResource_withoutInit_shouldThrowException() { + // Given + GenericKubernetesResource cr = createTestCR("test-cron"); + + // Then + assertThrows(IllegalStateException.class, () -> jGitAdapter.writeCustomResource(cr)); + } + + @Test + void deleteCustomResourceByPath_shouldMoveToArchive() throws Exception { + // Given + initializeAdapter(); + GenericKubernetesResource cr = createTestCR("test-cron"); + String crFilePath = jGitAdapter.writeCustomResource(cr, "res-123"); + + // When + boolean deleted = jGitAdapter.deleteCustomResourceByPath(crFilePath); + + // Then + assertTrue(deleted); + assertFalse(Files.exists(tempDir.resolve(crFilePath))); + assertTrue(Files.exists(tempDir.resolve("archive-CronTab/test-cron-res-123.yaml"))); + } + + @Test + void deleteCustomResourceByPath_withNonExistent_shouldReturnFalse() throws Exception { + // Given + initializeAdapter(); + + // When + boolean deleted = jGitAdapter.deleteCustomResourceByPath("non/existent/path.yaml"); + + // Then + assertFalse(deleted); + } + + @Test + void exists_shouldReturnTrueWhenFileExists() throws Exception { + // Given + initializeAdapter(); + GenericKubernetesResource cr = createTestCR("test-cron"); + jGitAdapter.writeCustomResource(cr); + + // When + boolean exists = jGitAdapter.exists("stable.example.com", "v1", "CronTab", "test-cron"); + + // Then + assertTrue(exists); + } + + @Test + void exists_shouldReturnFalseWhenFileDoesNotExist() throws Exception { + // Given + initializeAdapter(); + + // When + boolean exists = jGitAdapter.exists("stable.example.com", "v1", "CronTab", "non-existent"); + + // Then + assertFalse(exists); + } + + @Test + void isReady_shouldReturnFalseBeforeInit() { + // Then + assertFalse(jGitAdapter.isReady()); + } + + @Test + void isReady_shouldReturnTrueAfterInit() throws Exception { + // Given + initializeAdapter(); + + // Then + assertTrue(jGitAdapter.isReady()); + } + + @Test + void commit_shouldCreateCommitWithMessage() throws Exception { + // Given + initializeAdapter(); + GenericKubernetesResource cr = createTestCR("test-cron"); + jGitAdapter.writeCustomResource(cr); + + // When + String commitId = jGitAdapter.commit("[CREATE] CronTab test-cron", "corr-123"); + + // Then + assertNotNull(commitId); + assertFalse(commitId.isEmpty()); + } + + @Test + void close_shouldCleanupResources() throws Exception { + // Given + initializeAdapter(); + + // When + jGitAdapter.close(); + + // Then - should not throw and isReady should still return true (initialized flag stays) + assertNotNull(jGitAdapter); + } + + @Test + void writeCustomResource_shouldSerializeToYaml() throws Exception { + // Given + initializeAdapter(); + GenericKubernetesResource cr = createTestCR("test-cron"); + Map spec = new HashMap<>(); + spec.put("schedule", "*/5 * * * *"); + spec.put("image", "test-image:latest"); + cr.setAdditionalProperty("spec", spec); + + // When + String filePath = jGitAdapter.writeCustomResource(cr); + + // Then + String content = Files.readString(tempDir.resolve(filePath)); + assertTrue(content.contains("apiVersion:")); + assertTrue(content.contains("kind: CronTab")); + assertTrue(content.contains("name: test-cron")); + assertTrue(content.contains("schedule:")); + } + + private void initializeAdapter() throws Exception { + // Use reflection to set initialized flag and git object + var gitField = JGitAdapter.class.getDeclaredField("git"); + gitField.setAccessible(true); + gitField.set(jGitAdapter, Git.open(tempDir.toFile())); + + var initializedField = JGitAdapter.class.getDeclaredField("initialized"); + initializedField.setAccessible(true); + initializedField.set(jGitAdapter, true); + } + + private GenericKubernetesResource createTestCR(String name) { + GenericKubernetesResource cr = new GenericKubernetesResource(); + cr.setApiVersion("stable.example.com/v1"); + cr.setKind("CronTab"); + + ObjectMeta metadata = new ObjectMeta(); + metadata.setName(name); + cr.setMetadata(metadata); + + return cr; + } +} diff --git a/src/test/java/org/etsi/osl/controllers/giter/model/ReconciliationTaskTest.java b/src/test/java/org/etsi/osl/controllers/giter/model/ReconciliationTaskTest.java new file mode 100644 index 0000000000000000000000000000000000000000..2144513845611a756e596326ffb30fbb61f9db33 --- /dev/null +++ b/src/test/java/org/etsi/osl/controllers/giter/model/ReconciliationTaskTest.java @@ -0,0 +1,310 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.model; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.etsi.osl.controllers.giter.model.ReconciliationTask.ReconciliationState; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for ReconciliationTask model. + */ +class ReconciliationTaskTest { + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + } + + @Test + void builder_shouldCreateTaskWithDefaults() { + // When + ReconciliationTask task = ReconciliationTask.builder() + .taskId("task-1") + .tmfResourceId("res-123") + .crName("test-cr") + .build(); + + // Then + assertEquals("task-1", task.getTaskId()); + assertEquals("res-123", task.getTmfResourceId()); + assertEquals("test-cr", task.getCrName()); + assertEquals(ReconciliationState.PENDING, task.getState()); + assertEquals(0, task.getPollCount()); + } + + @Test + void isActive_shouldReturnTrueForPending() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .state(ReconciliationState.PENDING) + .build(); + + // Then + assertTrue(task.isActive()); + } + + @Test + void isActive_shouldReturnTrueForActive() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .state(ReconciliationState.ACTIVE) + .build(); + + // Then + assertTrue(task.isActive()); + } + + @Test + void isActive_shouldReturnFalseForCompleted() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .state(ReconciliationState.COMPLETED) + .build(); + + // Then + assertFalse(task.isActive()); + } + + @Test + void isActive_shouldReturnFalseForTimeout() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .state(ReconciliationState.TIMEOUT) + .build(); + + // Then + assertFalse(task.isActive()); + } + + @Test + void isActive_shouldReturnFalseForFailed() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .state(ReconciliationState.FAILED) + .build(); + + // Then + assertFalse(task.isActive()); + } + + @Test + void isTimedOut_shouldReturnFalseWhenNotExpired() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .timeoutAt(Instant.now().plusSeconds(3600)) + .build(); + + // Then + assertFalse(task.isTimedOut()); + } + + @Test + void isTimedOut_shouldReturnTrueWhenExpired() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .timeoutAt(Instant.now().minusSeconds(1)) + .build(); + + // Then + assertTrue(task.isTimedOut()); + } + + @Test + void shouldPoll_shouldReturnTrueWhenNeverPolled() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .lastPollAt(null) + .build(); + + // Then + assertTrue(task.shouldPoll(1000)); + } + + @Test + void shouldPoll_shouldReturnTrueWhenIntervalExceeded() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .lastPollAt(Instant.now().minusMillis(2000)) + .build(); + + // Then + assertTrue(task.shouldPoll(1000)); + } + + @Test + void shouldPoll_shouldReturnFalseWhenIntervalNotExceeded() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .lastPollAt(Instant.now().minusMillis(500)) + .build(); + + // Then + assertFalse(task.shouldPoll(1000)); + } + + @Test + void shouldPoll_shouldReturnFalseWhenExactlyAtInterval() { + // Given - exactly at interval boundary means not yet after + ReconciliationTask task = ReconciliationTask.builder() + .lastPollAt(Instant.now().minusMillis(1000)) + .build(); + + // Then - isAfter is strict, so exactly at boundary returns false + assertFalse(task.shouldPoll(1000)); + } + + @Test + void serializeToJson_shouldNotIncludeComputedFields() throws Exception { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .taskId("task-1") + .tmfResourceId("res-123") + .crName("test-cr") + .timeoutAt(Instant.now().plusSeconds(3600)) + .state(ReconciliationState.ACTIVE) + .build(); + + // When + String json = objectMapper.writeValueAsString(task); + + // Then + assertFalse(json.contains("\"active\"")); + assertFalse(json.contains("\"timedOut\"")); + assertTrue(json.contains("\"taskId\"")); + assertTrue(json.contains("\"tmfResourceId\"")); + assertTrue(json.contains("\"state\"")); + } + + @Test + void deserializeFromJson_shouldIgnoreUnknownFieldsWhenConfigured() throws Exception { + // Given + ObjectMapper lenientMapper = new ObjectMapper(); + lenientMapper.registerModule(new JavaTimeModule()); + lenientMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + String json = "{\"taskId\":\"task-1\",\"tmfResourceId\":\"res-123\",\"crName\":\"test-cr\",\"state\":\"ACTIVE\",\"unknown\":\"field\"}"; + + // When/Then - should not throw exception when configured to ignore unknown + ReconciliationTask task = lenientMapper.readValue(json, ReconciliationTask.class); + assertEquals("task-1", task.getTaskId()); + } + + @Test + void deserializeFromJson_shouldFailOnUnknownFieldsByDefault() { + // Given + String json = "{\"taskId\":\"task-1\",\"tmfResourceId\":\"res-123\",\"crName\":\"test-cr\",\"state\":\"ACTIVE\",\"unknown\":\"field\"}"; + + // Then - default ObjectMapper should fail on unknown properties + assertThrows(Exception.class, () -> objectMapper.readValue(json, ReconciliationTask.class)); + } + + @Test + void lastKnownStatus_shouldStoreMap() { + // Given + Map status = new HashMap<>(); + status.put("state", "Running"); + status.put("message", "All good"); + + ReconciliationTask task = ReconciliationTask.builder() + .lastKnownStatus(status) + .build(); + + // Then + assertEquals(status, task.getLastKnownStatus()); + assertEquals("Running", task.getLastKnownStatus().get("state")); + } + + @Test + void pollCount_shouldTrackIncrements() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .pollCount(0) + .build(); + + // When + task.setPollCount(task.getPollCount() + 1); + + // Then + assertEquals(1, task.getPollCount()); + } + + @Test + void errorMessage_shouldBeSettable() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .state(ReconciliationState.FAILED) + .errorMessage("Connection timeout") + .build(); + + // Then + assertEquals("Connection timeout", task.getErrorMessage()); + assertEquals(ReconciliationState.FAILED, task.getState()); + } + + @Test + void allFieldsShouldBeSerializable() throws Exception { + // Given + Map status = new HashMap<>(); + status.put("state", "Running"); + + ReconciliationTask task = ReconciliationTask.builder() + .taskId("task-full") + .tmfResourceId("res-full") + .apiVersion("stable.example.com/v1") + .kind("CronTab") + .crName("test-cron") + .crFilePath("resources/stable.example.com/v1/CronTab/test-cron.yaml") + .lastKnownStatus(status) + .startedAt(Instant.now()) + .timeoutAt(Instant.now().plusSeconds(30000)) + .lastPollAt(Instant.now()) + .pollCount(5) + .state(ReconciliationState.ACTIVE) + .errorMessage(null) + .build(); + + // When + String json = objectMapper.writeValueAsString(task); + ReconciliationTask deserialized = objectMapper.readValue(json, ReconciliationTask.class); + + // Then + assertEquals(task.getTaskId(), deserialized.getTaskId()); + assertEquals(task.getTmfResourceId(), deserialized.getTmfResourceId()); + assertEquals(task.getApiVersion(), deserialized.getApiVersion()); + assertEquals(task.getKind(), deserialized.getKind()); + assertEquals(task.getCrName(), deserialized.getCrName()); + assertEquals(task.getCrFilePath(), deserialized.getCrFilePath()); + assertEquals(task.getPollCount(), deserialized.getPollCount()); + assertEquals(task.getState(), deserialized.getState()); + } +} diff --git a/src/test/java/org/etsi/osl/controllers/giter/service/ResourceRepoServiceTest.java b/src/test/java/org/etsi/osl/controllers/giter/service/ResourceRepoServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..bf34a707539f28732387d622fb18f767f0210007 --- /dev/null +++ b/src/test/java/org/etsi/osl/controllers/giter/service/ResourceRepoServiceTest.java @@ -0,0 +1,318 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.service; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import org.etsi.osl.controllers.giter.adapter.git.GitAdapter; +import org.etsi.osl.controllers.giter.api.CatalogClient; +import org.etsi.osl.controllers.giter.model.ReconciliationTask; +import org.etsi.osl.tmf.common.model.Any; +import org.etsi.osl.tmf.ri639.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for ResourceRepoService. + */ +@ExtendWith(MockitoExtension.class) +class ResourceRepoServiceTest { + + @Mock + private CatalogClient catalogClient; + + @Mock + private ResourceMapper resourceMapper; + + @Mock + private ProducerService producerService; + + @Mock + private SimpleResourceMapper simpleResourceMapper; + + @Mock + private SchemaValidator schemaValidator; + + @Mock + private GitAdapter gitAdapter; + + @Mock + private StatusWatcherService statusWatcherService; + + @InjectMocks + private ResourceRepoService resourceRepoService; + + private Map headers; + private ResourceCreate resourceCreate; + private ResourceUpdate resourceUpdate; + private Resource resource; + + @BeforeEach + void setUp() { + headers = new HashMap<>(); + headers.put("org.etsi.osl.resourceId", "res-123"); + + resourceCreate = new ResourceCreate(); + resourceCreate.setName("test-resource"); + + resourceUpdate = new ResourceUpdate(); + resource = new Resource(); + resource.setUuid("res-123"); + } + + @Test + void createResource_withValidCR_shouldCommitAndStartReconciliation() throws Exception { + // Given + String crYaml = "apiVersion: stable.example.com/v1\nkind: CronTab\nmetadata:\n name: test-cron\nspec:\n schedule: '*/5 * * * *'"; + + Characteristic giterSpecChar = new Characteristic(); + giterSpecChar.setName("_GITER_SPEC"); + Any value = new Any(); + value.setValue(crYaml); + giterSpecChar.setValue(value); + + ResourceUpdate mappedUpdate = new ResourceUpdate(); + mappedUpdate.addResourceCharacteristicItem(giterSpecChar); + + when(simpleResourceMapper.resourceCreateToResourceUpdate(any())).thenReturn(mappedUpdate); + when(schemaValidator.validate(any())).thenReturn(SchemaValidator.ValidationResult.success()); + when(gitAdapter.writeCustomResource(any(), anyString())).thenReturn("resources/stable.example.com/v1/CronTab/test-cron-res-123.yaml"); + when(gitAdapter.commit(anyString(), anyString())).thenReturn("abc1234"); + when(statusWatcherService.startReconciliation(anyString(), any(), anyString())) + .thenReturn(ReconciliationTask.builder().taskId("task-1").build()); + when(catalogClient.updateResourceById(anyString(), any())).thenReturn(resource); + + // When + Resource result = resourceRepoService.createResource(headers, resourceCreate); + + // Then + assertNotNull(result); + verify(gitAdapter).writeCustomResource(any(GenericKubernetesResource.class), eq("res-123")); + verify(gitAdapter).commit(contains("[CREATE]"), anyString()); + verify(gitAdapter).push(); + verify(statusWatcherService).startReconciliation(eq("res-123"), any(), anyString()); + verify(catalogClient).updateResourceById(eq("res-123"), any()); + } + + @Test + void createResource_withInvalidCR_shouldNotCommit() throws Exception { + // Given + String crYaml = "invalid yaml content"; + + Characteristic giterSpecChar = new Characteristic(); + giterSpecChar.setName("_GITER_SPEC"); + Any value = new Any(); + value.setValue(crYaml); + giterSpecChar.setValue(value); + + ResourceUpdate mappedUpdate = new ResourceUpdate(); + mappedUpdate.addResourceCharacteristicItem(giterSpecChar); + + when(simpleResourceMapper.resourceCreateToResourceUpdate(any())).thenReturn(mappedUpdate); + when(catalogClient.updateResourceById(anyString(), any())).thenReturn(resource); + + // When + Resource result = resourceRepoService.createResource(headers, resourceCreate); + + // Then + assertNotNull(result); + verify(gitAdapter, never()).writeCustomResource(any(), anyString()); + verify(gitAdapter, never()).commit(anyString(), anyString()); + verify(statusWatcherService, never()).startReconciliation(anyString(), any(), anyString()); + } + + @Test + void createResource_withoutGiterSpec_shouldNotProcessCR() throws Exception { + // Given + ResourceUpdate mappedUpdate = new ResourceUpdate(); + Characteristic otherChar = new Characteristic(); + otherChar.setName("other-characteristic"); + mappedUpdate.addResourceCharacteristicItem(otherChar); + + when(simpleResourceMapper.resourceCreateToResourceUpdate(any())).thenReturn(mappedUpdate); + when(catalogClient.updateResourceById(anyString(), any())).thenReturn(resource); + + // When + Resource result = resourceRepoService.createResource(headers, resourceCreate); + + // Then + assertNotNull(result); + verify(gitAdapter, never()).writeCustomResource(any(), anyString()); + verify(gitAdapter, never()).commit(anyString(), anyString()); + } + + @Test + void updateResource_withValidCR_shouldUpdateInGit() throws Exception { + // Given + String crYaml = "apiVersion: stable.example.com/v1\nkind: CronTab\nmetadata:\n name: test-cron\nspec:\n schedule: '*/10 * * * *'"; + + Characteristic giterSpecChar = new Characteristic(); + giterSpecChar.setName("_GITER_SPEC"); + Any value = new Any(); + value.setValue(crYaml); + giterSpecChar.setValue(value); + resourceUpdate.addResourceCharacteristicItem(giterSpecChar); + + when(schemaValidator.validate(any())).thenReturn(SchemaValidator.ValidationResult.success()); + when(gitAdapter.writeCustomResource(any(), anyString())).thenReturn("resources/stable.example.com/v1/CronTab/test-cron-res-123.yaml"); + when(gitAdapter.commit(anyString(), anyString())).thenReturn("def5678"); + when(statusWatcherService.getCrFilePathForResource(anyString())).thenReturn("existing-path"); + when(catalogClient.updateResourceById(anyString(), any())).thenReturn(resource); + + // When + Resource result = resourceRepoService.updateResource(headers, resourceUpdate); + + // Then + assertNotNull(result); + verify(gitAdapter).writeCustomResource(any(GenericKubernetesResource.class), eq("res-123")); + verify(gitAdapter).commit(contains("[UPDATE]"), anyString()); + verify(gitAdapter).push(); + // Should not start new reconciliation since one already exists + verify(statusWatcherService, never()).startReconciliation(anyString(), any(), anyString()); + } + + @Test + void updateResource_withNoExistingReconciliation_shouldStartNew() throws Exception { + // Given + String crYaml = "apiVersion: stable.example.com/v1\nkind: CronTab\nmetadata:\n name: test-cron\nspec:\n schedule: '*/10 * * * *'"; + + Characteristic giterSpecChar = new Characteristic(); + giterSpecChar.setName("_GITER_SPEC"); + Any value = new Any(); + value.setValue(crYaml); + giterSpecChar.setValue(value); + resourceUpdate.addResourceCharacteristicItem(giterSpecChar); + + when(schemaValidator.validate(any())).thenReturn(SchemaValidator.ValidationResult.success()); + when(gitAdapter.writeCustomResource(any(), anyString())).thenReturn("resources/stable.example.com/v1/CronTab/test-cron-res-123.yaml"); + when(gitAdapter.commit(anyString(), anyString())).thenReturn("def5678"); + when(statusWatcherService.getCrFilePathForResource(anyString())).thenReturn(null); + when(statusWatcherService.startReconciliation(anyString(), any(), anyString())) + .thenReturn(ReconciliationTask.builder().taskId("task-2").build()); + when(catalogClient.updateResourceById(anyString(), any())).thenReturn(resource); + + // When + Resource result = resourceRepoService.updateResource(headers, resourceUpdate); + + // Then + assertNotNull(result); + verify(statusWatcherService).startReconciliation(eq("res-123"), any(), anyString()); + } + + @Test + void deleteResource_shouldStopReconciliationAndArchive() throws Exception { + // Given + String crFilePath = "resources/stable.example.com/v1/CronTab/test-cron-res-123.yaml"; + + when(statusWatcherService.getCrFilePathForResource("res-123")).thenReturn(crFilePath); + when(statusWatcherService.stopReconciliation("res-123")).thenReturn(true); + when(gitAdapter.deleteCustomResourceByPath(crFilePath)).thenReturn(true); + when(gitAdapter.commit(anyString(), anyString())).thenReturn("ghi9012"); + when(catalogClient.updateResourceById(anyString(), any())).thenReturn(resource); + + // When + Resource result = resourceRepoService.deleteResource(headers, resourceUpdate); + + // Then + assertNotNull(result); + verify(statusWatcherService).stopReconciliation("res-123"); + verify(gitAdapter).deleteCustomResourceByPath(crFilePath); + verify(gitAdapter).commit(contains("[DELETE]"), eq("res-123")); + verify(gitAdapter).push(); + } + + @Test + void deleteResource_withNoCrPath_shouldStillUpdateTMF() throws Exception { + // Given + when(statusWatcherService.getCrFilePathForResource("res-123")).thenReturn(null); + when(catalogClient.updateResourceById(anyString(), any())).thenReturn(resource); + + // When + Resource result = resourceRepoService.deleteResource(headers, resourceUpdate); + + // Then + assertNotNull(result); + verify(gitAdapter, never()).deleteCustomResourceByPath(anyString()); + verify(catalogClient).updateResourceById(eq("res-123"), any()); + } + + @Test + void deleteResource_whenGitDeleteFails_shouldContinue() throws Exception { + // Given + String crFilePath = "resources/stable.example.com/v1/CronTab/test-cron-res-123.yaml"; + + when(statusWatcherService.getCrFilePathForResource("res-123")).thenReturn(crFilePath); + when(statusWatcherService.stopReconciliation("res-123")).thenReturn(true); + when(gitAdapter.deleteCustomResourceByPath(crFilePath)).thenThrow(new IOException("Git error")); + when(catalogClient.updateResourceById(anyString(), any())).thenReturn(resource); + + // When + Resource result = resourceRepoService.deleteResource(headers, resourceUpdate); + + // Then + assertNotNull(result); + verify(catalogClient).updateResourceById(eq("res-123"), any()); + } + + @Test + void extractResourceId_shouldReturnIdFromHeaders() { + // Given + Map testHeaders = new HashMap<>(); + testHeaders.put("org.etsi.osl.resourceId", "test-id-456"); + + ResourceUpdate update = new ResourceUpdate(); + when(simpleResourceMapper.resourceCreateToResourceUpdate(any())).thenReturn(update); + when(catalogClient.updateResourceById(anyString(), any())).thenReturn(resource); + + // When + resourceRepoService.createResource(testHeaders, resourceCreate); + + // Then + verify(catalogClient).updateResourceById(eq("test-id-456"), any()); + } + + @Test + void extractResourceId_shouldReturnEmptyWhenMissing() { + // Given + Map testHeaders = new HashMap<>(); + + ResourceUpdate update = new ResourceUpdate(); + when(simpleResourceMapper.resourceCreateToResourceUpdate(any())).thenReturn(update); + when(catalogClient.updateResourceById(anyString(), any())).thenReturn(resource); + + // When + resourceRepoService.createResource(testHeaders, resourceCreate); + + // Then + verify(catalogClient).updateResourceById(eq(""), any()); + } +} diff --git a/src/test/java/org/etsi/osl/controllers/giter/service/StatusWatcherServiceTest.java b/src/test/java/org/etsi/osl/controllers/giter/service/StatusWatcherServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..faff30c23a4c5b737bd158fca9b7629fc8c5b451 --- /dev/null +++ b/src/test/java/org/etsi/osl/controllers/giter/service/StatusWatcherServiceTest.java @@ -0,0 +1,331 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.controllers.giter.service; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import org.etsi.osl.controllers.giter.adapter.git.GitAdapter; +import org.etsi.osl.controllers.giter.api.CatalogClient; +import org.etsi.osl.controllers.giter.config.ProducerProperties; +import org.etsi.osl.controllers.giter.model.ReconciliationTask; +import org.etsi.osl.controllers.giter.model.ReconciliationTask.ReconciliationState; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for StatusWatcherService. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class StatusWatcherServiceTest { + + @Mock + private ProducerProperties producerProperties; + + @Mock + private GitAdapter gitAdapter; + + @Mock + private CatalogClient catalogClient; + + @TempDir + Path tempDir; + + private StatusWatcherService statusWatcherService; + private ProducerProperties.GitProperties gitProperties; + private ProducerProperties.StatusWatcherProperties statusWatcherProperties; + private ProducerProperties.CrdProperties crdProperties; + + @BeforeEach + void setUp() { + gitProperties = new ProducerProperties.GitProperties(); + gitProperties.setLocalPath(tempDir.toString()); + + statusWatcherProperties = new ProducerProperties.StatusWatcherProperties(); + statusWatcherProperties.setPollIntervalMs(1000); + statusWatcherProperties.setTimeoutMs(30000); + statusWatcherProperties.setStatusMappings(new ArrayList<>()); + statusWatcherProperties.setDefaultResourceStatusType("UNKNOWN"); + statusWatcherProperties.setDefaultOperationalStateType("DISABLE"); + + crdProperties = new ProducerProperties.CrdProperties(); + crdProperties.setStatusFieldPath("status.state"); + + when(producerProperties.getGit()).thenReturn(gitProperties); + when(producerProperties.getStatusWatcher()).thenReturn(statusWatcherProperties); + when(producerProperties.getCrd()).thenReturn(crdProperties); + + statusWatcherService = new StatusWatcherService(); + + // Use reflection to inject mocks + try { + var producerPropertiesField = StatusWatcherService.class.getDeclaredField("producerProperties"); + producerPropertiesField.setAccessible(true); + producerPropertiesField.set(statusWatcherService, producerProperties); + + var gitAdapterField = StatusWatcherService.class.getDeclaredField("gitAdapter"); + gitAdapterField.setAccessible(true); + gitAdapterField.set(statusWatcherService, gitAdapter); + + var catalogClientField = StatusWatcherService.class.getDeclaredField("catalogClient"); + catalogClientField.setAccessible(true); + catalogClientField.set(statusWatcherService, catalogClient); + } catch (Exception e) { + fail("Failed to inject mocks: " + e.getMessage()); + } + + statusWatcherService.initialize(); + } + + @Test + void startReconciliation_shouldCreateTask() { + // Given + GenericKubernetesResource cr = createTestCR("test-cr"); + + // When + ReconciliationTask task = statusWatcherService.startReconciliation( + "res-123", cr, "resources/example.com/v1/CronTab/test-cr-res-123.yaml"); + + // Then + assertNotNull(task); + assertNotNull(task.getTaskId()); + assertEquals("res-123", task.getTmfResourceId()); + assertEquals("test-cr", task.getCrName()); + assertEquals("CronTab", task.getKind()); + assertEquals(ReconciliationState.ACTIVE, task.getState()); + assertNotNull(task.getStartedAt()); + assertNotNull(task.getTimeoutAt()); + assertTrue(task.getTimeoutAt().isAfter(task.getStartedAt())); + } + + @Test + void startReconciliation_shouldIncrementActiveTaskCount() { + // Given + GenericKubernetesResource cr = createTestCR("test-cr"); + + // When + statusWatcherService.startReconciliation("res-1", cr, "path1"); + statusWatcherService.startReconciliation("res-2", cr, "path2"); + statusWatcherService.startReconciliation("res-3", cr, "path3"); + + // Then + assertEquals(3, statusWatcherService.getActiveTaskCount()); + } + + @Test + void stopReconciliation_shouldMarkTaskAsCompleted() { + // Given + GenericKubernetesResource cr = createTestCR("test-cr"); + statusWatcherService.startReconciliation("res-123", cr, "path"); + + // When + boolean stopped = statusWatcherService.stopReconciliation("res-123"); + + // Then + assertTrue(stopped); + assertEquals(0, statusWatcherService.getActiveTaskCount()); + } + + @Test + void stopReconciliation_withNonExistentTask_shouldReturnFalse() { + // When + boolean stopped = statusWatcherService.stopReconciliation("non-existent"); + + // Then + assertFalse(stopped); + } + + @Test + void stopReconciliationByCrPath_shouldMarkTaskAsCompleted() { + // Given + GenericKubernetesResource cr = createTestCR("test-cr"); + String crPath = "resources/example.com/v1/CronTab/test-cr-res-123.yaml"; + statusWatcherService.startReconciliation("res-123", cr, crPath); + + // When + boolean stopped = statusWatcherService.stopReconciliationByCrPath(crPath); + + // Then + assertTrue(stopped); + assertEquals(0, statusWatcherService.getActiveTaskCount()); + } + + @Test + void getCrFilePathForResource_shouldReturnPath() { + // Given + GenericKubernetesResource cr = createTestCR("test-cr"); + String crPath = "resources/example.com/v1/CronTab/test-cr-res-123.yaml"; + statusWatcherService.startReconciliation("res-123", cr, crPath); + + // When + String result = statusWatcherService.getCrFilePathForResource("res-123"); + + // Then + assertEquals(crPath, result); + } + + @Test + void getCrFilePathForResource_withNonExistentTask_shouldReturnNull() { + // When + String result = statusWatcherService.getCrFilePathForResource("non-existent"); + + // Then + assertNull(result); + } + + @Test + void getActiveTasks_shouldReturnCopy() { + // Given + GenericKubernetesResource cr = createTestCR("test-cr"); + statusWatcherService.startReconciliation("res-1", cr, "path1"); + statusWatcherService.startReconciliation("res-2", cr, "path2"); + + // When + Map tasks = statusWatcherService.getActiveTasks(); + + // Then + assertEquals(2, tasks.size()); + } + + @Test + void pollActiveTasks_whenGitNotReady_shouldSkip() { + // Given + GenericKubernetesResource cr = createTestCR("test-cr"); + statusWatcherService.startReconciliation("res-123", cr, "path"); + when(gitAdapter.isReady()).thenReturn(false); + + // When + statusWatcherService.pollActiveTasks(); + + // Then + try { + verify(gitAdapter, never()).pull(); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + } + + @Test + void pollActiveTasks_whenNoActiveTasks_shouldReturn() { + // When + statusWatcherService.pollActiveTasks(); + + // Then + verify(gitAdapter, never()).isReady(); + } + + @Test + void reconciliationTask_isActive_shouldReturnTrueForActiveStates() { + // Given + ReconciliationTask pendingTask = ReconciliationTask.builder() + .state(ReconciliationState.PENDING) + .build(); + + ReconciliationTask activeTask = ReconciliationTask.builder() + .state(ReconciliationState.ACTIVE) + .build(); + + ReconciliationTask completedTask = ReconciliationTask.builder() + .state(ReconciliationState.COMPLETED) + .build(); + + // Then + assertTrue(pendingTask.isActive()); + assertTrue(activeTask.isActive()); + assertFalse(completedTask.isActive()); + } + + @Test + void reconciliationTask_isTimedOut_shouldCheckTimeoutAt() { + // Given + ReconciliationTask notTimedOut = ReconciliationTask.builder() + .timeoutAt(Instant.now().plusSeconds(3600)) + .build(); + + ReconciliationTask timedOut = ReconciliationTask.builder() + .timeoutAt(Instant.now().minusSeconds(1)) + .build(); + + // Then + assertFalse(notTimedOut.isTimedOut()); + assertTrue(timedOut.isTimedOut()); + } + + @Test + void reconciliationTask_shouldPoll_whenNeverPolled() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .lastPollAt(null) + .build(); + + // Then + assertTrue(task.shouldPoll(1000)); + } + + @Test + void reconciliationTask_shouldPoll_whenIntervalPassed() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .lastPollAt(Instant.now().minusMillis(2000)) + .build(); + + // Then + assertTrue(task.shouldPoll(1000)); + } + + @Test + void reconciliationTask_shouldNotPoll_whenIntervalNotPassed() { + // Given + ReconciliationTask task = ReconciliationTask.builder() + .lastPollAt(Instant.now().minusMillis(500)) + .build(); + + // Then + assertFalse(task.shouldPoll(1000)); + } + + private GenericKubernetesResource createTestCR(String name) { + GenericKubernetesResource cr = new GenericKubernetesResource(); + cr.setApiVersion("stable.example.com/v1"); + cr.setKind("CronTab"); + + ObjectMeta metadata = new ObjectMeta(); + metadata.setName(name); + cr.setMetadata(metadata); + + return cr; + } +} diff --git a/src/test/java/org/etsi/osl/example/gc/exception/ExceptionTest.java b/src/test/java/org/etsi/osl/example/gc/exception/ExceptionTest.java new file mode 100644 index 0000000000000000000000000000000000000000..72cb5ff2bf2580fdd41371187b50a431f1062bc0 --- /dev/null +++ b/src/test/java/org/etsi/osl/example/gc/exception/ExceptionTest.java @@ -0,0 +1,317 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.controllers.giter + * %% + * Copyright (C) 2025 osl.etsi.org + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.example.gc.exception; + +import org.etsi.osl.controllers.giter.exception.GitOperationException; +import org.etsi.osl.controllers.giter.exception.ProducerException; +import org.etsi.osl.controllers.giter.exception.SchemaValidationException; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for custom exception classes. + * + * @author ctranoris + */ +class ExceptionTest { + + @Test + void testProducerException_SimpleConstructor() { + // When + ProducerException ex = new ProducerException("Test message"); + + // Then + assertEquals("Test message", ex.getMessage()); + assertNull(ex.getCorrelationId()); + assertEquals(ProducerException.ErrorType.UNKNOWN, ex.getErrorType()); + assertNull(ex.getCause()); + } + + @Test + void testProducerException_WithCause() { + // Given + Throwable cause = new RuntimeException("Root cause"); + + // When + ProducerException ex = new ProducerException("Test message", cause); + + // Then + assertEquals("Test message", ex.getMessage()); + assertNull(ex.getCorrelationId()); + assertEquals(cause, ex.getCause()); + } + + @Test + void testProducerException_WithCorrelationId() { + // When + ProducerException ex = new ProducerException("Test message", "corr-123"); + + // Then + assertEquals("Test message", ex.getMessage()); + assertEquals("corr-123", ex.getCorrelationId()); + } + + @Test + void testProducerException_WithCorrelationIdAndCause() { + // Given + Throwable cause = new RuntimeException("Root cause"); + + // When + ProducerException ex = new ProducerException("Test message", "corr-456", cause); + + // Then + assertEquals("Test message", ex.getMessage()); + assertEquals("corr-456", ex.getCorrelationId()); + assertEquals(cause, ex.getCause()); + } + + @Test + void testProducerException_WithErrorType() { + // When + ProducerException ex = new ProducerException( + "Test message", + "corr-789", + ProducerException.ErrorType.VALIDATION + ); + + // Then + assertEquals("Test message", ex.getMessage()); + assertEquals("corr-789", ex.getCorrelationId()); + assertEquals(ProducerException.ErrorType.VALIDATION, ex.getErrorType()); + } + + @Test + void testProducerException_FullConstructor() { + // Given + Throwable cause = new RuntimeException("Root cause"); + + // When + ProducerException ex = new ProducerException( + "Test message", + "corr-full", + ProducerException.ErrorType.GIT_OPERATION, + cause + ); + + // Then + assertEquals("Test message", ex.getMessage()); + assertEquals("corr-full", ex.getCorrelationId()); + assertEquals(ProducerException.ErrorType.GIT_OPERATION, ex.getErrorType()); + assertEquals(cause, ex.getCause()); + } + + @Test + void testProducerException_ToString() { + // When + ProducerException ex = new ProducerException( + "Test message", + "corr-toString", + ProducerException.ErrorType.NETWORK + ); + + String str = ex.toString(); + + // Then + assertTrue(str.contains("ProducerException")); + assertTrue(str.contains("corr-toString")); + assertTrue(str.contains("NETWORK")); + assertTrue(str.contains("Test message")); + } + + @Test + void testSchemaValidationException_Simple() { + // When + SchemaValidationException ex = new SchemaValidationException( + "Validation failed", + "corr-val-123" + ); + + // Then + assertEquals("corr-val-123", ex.getCorrelationId()); + assertEquals(ProducerException.ErrorType.VALIDATION, ex.getErrorType()); + assertNull(ex.getValidationErrors()); + assertNull(ex.getResourceName()); + } + + @Test + void testSchemaValidationException_WithErrors() { + // Given + List errors = Arrays.asList( + "$.spec.schedule: is missing but it is required", + "$.spec.replicas: integer expected, string found" + ); + + // When + SchemaValidationException ex = new SchemaValidationException( + "Validation failed", + "corr-val-456", + errors + ); + + // Then + assertEquals("corr-val-456", ex.getCorrelationId()); + assertEquals(errors, ex.getValidationErrors()); + assertTrue(ex.getMessage().contains("$.spec.schedule")); + assertTrue(ex.getMessage().contains("$.spec.replicas")); + } + + @Test + void testSchemaValidationException_WithResourceNameAndErrors() { + // Given + List errors = Arrays.asList("Error 1", "Error 2"); + + // When + SchemaValidationException ex = new SchemaValidationException( + "Validation failed", + "corr-val-789", + "test-resource", + errors + ); + + // Then + assertEquals("corr-val-789", ex.getCorrelationId()); + assertEquals("test-resource", ex.getResourceName()); + assertEquals(errors, ex.getValidationErrors()); + assertTrue(ex.getMessage().contains("test-resource")); + assertTrue(ex.getMessage().contains("Error 1")); + assertTrue(ex.getMessage().contains("Error 2")); + } + + @Test + void testGitOperationException_Simple() { + // When + GitOperationException ex = new GitOperationException( + "Git operation failed", + GitOperationException.GitOperation.PUSH + ); + + // Then + assertEquals("Git operation failed", ex.getMessage()); + assertEquals(GitOperationException.GitOperation.PUSH, ex.getOperation()); + assertNull(ex.getCorrelationId()); + assertNull(ex.getFilePath()); + assertFalse(ex.isRetryable()); + } + + @Test + void testGitOperationException_WithCause() { + // Given + Throwable cause = new RuntimeException("Network error"); + + // When + GitOperationException ex = new GitOperationException( + "Push failed", + GitOperationException.GitOperation.PUSH, + cause + ); + + // Then + assertEquals("Push failed", ex.getMessage()); + assertEquals(GitOperationException.GitOperation.PUSH, ex.getOperation()); + assertEquals(cause, ex.getCause()); + } + + @Test + void testGitOperationException_WithCorrelationId() { + // When + GitOperationException ex = new GitOperationException( + "Commit failed", + "corr-git-123", + GitOperationException.GitOperation.COMMIT + ); + + // Then + assertEquals("corr-git-123", ex.getCorrelationId()); + assertEquals(GitOperationException.GitOperation.COMMIT, ex.getOperation()); + } + + @Test + void testGitOperationException_Full() { + // Given + Throwable cause = new RuntimeException("File system error"); + + // When + GitOperationException ex = new GitOperationException( + "Write failed", + "corr-git-full", + GitOperationException.GitOperation.WRITE, + "resources/example.com/v1/CronTab/test.yaml", + true, + cause + ); + + // Then + assertEquals("Write failed", ex.getMessage()); + assertEquals("corr-git-full", ex.getCorrelationId()); + assertEquals(GitOperationException.GitOperation.WRITE, ex.getOperation()); + assertEquals("resources/example.com/v1/CronTab/test.yaml", ex.getFilePath()); + assertTrue(ex.isRetryable()); + assertEquals(cause, ex.getCause()); + } + + @Test + void testGitOperationException_ToString() { + // When + GitOperationException ex = new GitOperationException( + "Operation failed", + "corr-git-toString", + GitOperationException.GitOperation.CONFLICT, + "test-file.yaml", + true + ); + + String str = ex.toString(); + + // Then + assertTrue(str.contains("GitOperationException")); + assertTrue(str.contains("CONFLICT")); + assertTrue(str.contains("corr-git-toString")); + assertTrue(str.contains("test-file.yaml")); + assertTrue(str.contains("retryable")); + } + + @Test + void testGitOperation_EnumValues() { + // Then - verify all operations have descriptions + for (GitOperationException.GitOperation op : GitOperationException.GitOperation.values()) { + assertNotNull(op.getDescription()); + assertFalse(op.getDescription().isEmpty()); + } + } + + @Test + void testErrorType_EnumValues() { + // Then - verify all error types exist + assertNotNull(ProducerException.ErrorType.UNKNOWN); + assertNotNull(ProducerException.ErrorType.VALIDATION); + assertNotNull(ProducerException.ErrorType.GIT_OPERATION); + assertNotNull(ProducerException.ErrorType.MESSAGING); + assertNotNull(ProducerException.ErrorType.SCHEMA_LOADING); + assertNotNull(ProducerException.ErrorType.RESOURCE_MAPPING); + assertNotNull(ProducerException.ErrorType.CONFIGURATION); + assertNotNull(ProducerException.ErrorType.NETWORK); + assertNotNull(ProducerException.ErrorType.AUTHENTICATION); + assertNotNull(ProducerException.ErrorType.TIMEOUT); + } +}