diff --git a/.agents/skills/create-commit/SKILL.md b/.agents/skills/create-commit/SKILL.md new file mode 100644 index 0000000..ade7130 --- /dev/null +++ b/.agents/skills/create-commit/SKILL.md @@ -0,0 +1,112 @@ +--- +name: create-commit +description: Follow the project's commit message conventions when making a git commit. Use this whenever you are about to run `git commit`. +--- + +# Create Commit + +## Commit Message Rules + +### COMMIT-1: Commit Messages + +**RULE**: Commit messages should be loosely based on the rules of [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) but without prefixes. Write a clear, concise summary of what the change does, starting with a lowercase verb in imperative mood. + +**DO**: + +```text +add the BucketAccess custom resource +fix the bucket deletion when Garage answers 409 +remove the backend probes from the readiness check +update Garage to 2.4.1 +rename the admin token key to admin_token +extract the Garage admin client into a factory +``` + +**DON'T**: + +```text +// Incorrect - using conventional commit prefixes +feat: add the BucketAccess custom resource +fix: the bucket deletion when Garage answers 409 +refactor: extract the Garage admin client into a factory + +// Incorrect - not imperative mood +added the BucketAccess custom resource +adding the BucketAccess custom resource + +// Incorrect - including ticket IDs +#123 add the BucketAccess custom resource +[#123] fix the bucket deletion when Garage answers 409 + +// Incorrect - vague or meaningless +fix bug +update code +changes +WIP + +// Incorrect - too long, should be concise +fix the bucket deletion because Garage answers 409 instead of 400 for a bucket that still holds objects and the operator then reported a generic error that confused the administrators +``` + +### COMMIT-2: Commit Body Only When Necessary + +**RULE**: Most commits have only the summary line. Add a body only when a human reader needs context that the summary +and the diff cannot give: the reason for a non-obvious decision, a constraint outside the code, or a consequence that +the reader would otherwise miss. Keep the body short: a few lines, not a list of every changed file or step. + +**DON'T** repeat in the body what the diff already shows: which files changed, which methods were renamed, or a +step-by-step account of the work. + +**DO**: + +```text +address buckets and keys by their recorded id + +Garage does not enforce unique key names, so a lookup by name could pick up +or delete a key that belongs to another AccessKey. +``` + +**DON'T**: + +```text +update the AccessKey reconciler + +- Changed AccessKeyReconciler.java +- Added findManagedAccessKey +- Renamed the cleanup variables +- Updated the imports +- Reformatted the file +``` + +### COMMIT-3: AI Attribution with `Assisted-by` + +**RULE**: A commit that an AI agent wrote or helped to write ends with an `Assisted-by:` trailer in the format +`AGENT_NAME:MODEL_VERSION`, without spaces (e.g. `Claude:claude-opus-5-5`, `Junie:`). Never use +`Co-Authored-By:` for an AI agent: that trailer is for human co-authors. This rule replaces any default attribution of +the agent. + +**RATIONALE**: The format comes from the Linux kernel guide for AI coding assistants. The kernel changed it to +`Assisted-by: LLM` in Linux 7.3. This project keeps the agent and the model, because they tell a reviewer which agent +(Claude Code or Junie) and which model made the change. + +**DO**: + +```text +fix the bucket deletion when Garage answers 409 + +Assisted-by: Claude:claude-opus-5-5 +``` + +```text +add the Secret watch to the AccessKey reconciler + +Assisted-by: Junie: +``` + +**DON'T**: + +```text +fix the bucket deletion when Garage answers 409 + +Co-Authored-By: Claude Opus 5.5 +``` diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4361d2f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* +!build/*-runner +!build/*-runner.jar +!build/lib/* +!build/quarkus-app/* \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c55acdf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 +ij_continuation_indent_size = 8 + +[{*.yml,*.yaml}] +indent_size = 2 + +[*.md] +max_line_length = off +indent_size = 2 + +[Makefile*] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4f19230 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Line endings: store LF in the repository and check out LF on every platform. +# Git detects binary files itself through text=auto. The entries below are the exceptions. +* text=auto eol=lf + +# Windows batch files need CRLF in the working tree. +*.bat text eol=crlf + +# Binary files +*.jar binary diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..369f90c --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e +set -o pipefail + +./gradlew --console=colored checkstyleMain checkstyleTest diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..cc34a7f --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,18 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + types: [ opened, reopened, synchronize ] + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Tests + uses: ./.github/workflows/test.yml + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a4fd4fd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Release + +on: + workflow_dispatch: + inputs: + increment: + description: "Version increment type" + type: choice + required: true + default: "Patch" + options: + - "Major" + - "Minor" + - "Patch" + - "Prerelease" + +env: + DOCKER_IMAGE: ghcr.io/${{ github.repository }} + +jobs: + test: + uses: ./.github/workflows/test.yml + secrets: inherit + + build-and-release: + needs: test + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + with: + token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + - uses: aboutbits/github-actions-base/git-setup@v2 + - uses: aboutbits/github-actions-java/setup-with-gradle@v4 + with: + java-version: 25 + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Increment version + run: ./gradlew --console=colored createRelease -Prelease.versionIncrementer=increment${{ github.event.inputs.increment }} + shell: bash + - name: Get next package version + id: nextVersion + run: echo "version=$(./gradlew currentVersion -q -Prelease.quiet)" >> $GITHUB_OUTPUT + shell: bash + - name: Build package + run: ./gradlew --console=colored build -x test + env: + GITHUB_USER_NAME: ${{ github.actor }} + GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: aboutbits/github-actions-docker/build-push@v1 + with: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + docker-image: ${{ env.DOCKER_IMAGE }} + docker-tag: ${{ steps.nextVersion.outputs.version }} + working-directory: './operator' + dockerfile: './operator/src/main/docker/Dockerfile.jvm' + - name: Push tag to remote + run: ./gradlew --console=colored pushRelease + shell: bash + - uses: aboutbits/github-actions-base/github-create-release@v2 + with: + tag-name: 'v${{ steps.nextVersion.outputs.version }}' + release-description: | + ## Installation + + ### Helm Chart + ```bash + helm install garage-operator https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/garage-operator-${{ steps.nextVersion.outputs.version }}.tgz + ``` + + With the Helm chart, the Custom Resource Definitions (CRDs) are installed automatically. + However, if you deploy the operator directly from the OCI image, the CRDs are not automatically applied and must be installed separately. + + ### Manual CRD Installation + + The CRD manifests are attached to this release as `*.garage.aboutbits.it-v1.yml` assets and can be applied directly, for example: + ```bash + kubectl apply -f https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/garageclusters.garage.aboutbits.it-v1.yml + ``` + + ## Upgrading + + Helm never upgrades CRDs, so apply the CRDs of this release **before** upgrading the chart: + ```bash + for crd in garageclusters s3connections buckets accesskeys bucketaccesses; do + kubectl apply --server-side --force-conflicts -f https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/${crd}.garage.aboutbits.it-v1.yml + done + + helm upgrade garage-operator https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/garage-operator-${{ steps.nextVersion.outputs.version }}.tgz + ``` + release-notes-generation: 'true' + - name: Upload Helm chart and CRD assets + env: + GH_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + run: | + gh release upload v${{ steps.nextVersion.outputs.version }} operator/build/helm/kubernetes/garage-operator-${{ steps.nextVersion.outputs.version }}.tgz operator/build/kubernetes/*.garage.aboutbits.it-v1.yml + shell: bash + - name: Update readme.md + run: | + sed -i "s|releases/download/v[0-9.]*/garage-operator-[0-9.]*.tgz|releases/download/v${{ steps.nextVersion.outputs.version }}/garage-operator-${{ steps.nextVersion.outputs.version }}.tgz|g" readme.md + git add readme.md + git diff-index --quiet HEAD || git commit -m "update readme.md with version ${{ steps.nextVersion.outputs.version }}" + git push + shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..13fb0da --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + workflow_call: + +jobs: + test: + name: Tests + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: aboutbits/github-actions-java/setup-with-gradle@v4 + with: + java-version: 25 + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Build & Test + run: >- + ./gradlew + --console=colored + :operator:test + --fail-fast + env: + GITHUB_USER_NAME: ${{ github.actor }} + GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50b8a8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +### Garage Operator ### +config/ +# The kubectl cache of the Dev Services cluster +operator/.kube/ +.rumdl_cache/ +.claude/settings.local.json +.claude/worktrees/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea/* +!.idea/codeStyles +!.idea/.gitignore +!.idea/checkstyle-idea.xml +!.idea/encodings.xml +!.idea/misc.xml +!.idea/sqldialects.xml +!.idea/vcs.xml + +*.iml +*.ipr +*.iws + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac ### +.DS_Store + +### Gradle ### +# Reference: https://github.com/github/gitignore/blob/main/Gradle.gitignore +.gradle +**/build/ +!**/src/**/build/ +gradle-app.setting +!gradle-wrapper.jar +!gradle-wrapper.properties +.gradletasknamecache + +### Quarkus ### +# Local environment +.env + +# Plugin directory +/.quarkus/cli/plugins/ + +# Quinoa +.quinoa/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..eb37e5a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.junie/skills b/.junie/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.junie/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.rumdl.toml b/.rumdl.toml new file mode 100644 index 0000000..a3a6bf4 --- /dev/null +++ b/.rumdl.toml @@ -0,0 +1,4 @@ +# rumdl configuration, see https://github.com/rvben/rumdl +[global] +# Markdown lines are not wrapped, see max_line_length in .editorconfig +disable = ["MD013"] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d20bc9e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,72 @@ +# AboutBits Garage Operator - Agent Guidelines + +This is the primary guide for AI agents and developers. It provides project context, operational commands, and links to +the skills. The skills live in [`.agents/`](./.agents). + +## Contain Changes — Scope, Not Diff + +When asked to fix or change one thing, keep the **scope** to that thing: do not refactor code the +task does not touch unless you flag it first and the user agrees. If a task is getting complex and +you're about to make many interconnected changes, pause and summarize the plan before executing. + +Inside that scope, the size of the diff is not a criterion. Code the task does touch takes the shape +it should have, whether it existed before or not. Never build *around* existing code to avoid +changing it — a wrapper, a flag, a second copy of a class somewhere else — because it means +"fewer changes". Each such layer is cheap on the day and permanent afterwards. + +## Greenfield Within a PR + +**The code a PR introduces is greenfield: it can and should be amended while the PR is still open.** +We are after a good solution, not a preserved history. If something written an hour ago turns out to +need a different shape, reshape it — there is nothing to "salvage" from an unmerged PR, and no need +to layer a fix on top of code that only exists on this branch. A reviewer reads the final state. + +This is about the PR's **own** new code. Code the PR merely touches is not greenfield: it follows +[Contain Changes](#contain-changes--scope-not-diff) above. + +## 🎯 Project Intent (The "Why") + +The **Garage Operator** manages S3 object storage on Kubernetes declaratively. It is the companion of the +[AboutBits Garage Helm chart](https://github.com/aboutbits/helm-garage): the chart runs Garage, the operator assigns its +cluster layout and manages buckets, access keys and their permissions as Custom Resources. + +It follows the structure of the sibling [AboutBits PostgreSQL Operator](https://github.com/aboutbits/postgresql-operator). +When in doubt about a pattern, look there first. + +## 🚀 Project Overview + +- **Stack**: Java 25, Quarkus, the Quarkus Operator SDK and the fabric8 Kubernetes client. +- **Generated artifacts**: the CRDs and the Helm chart are generated from the code at build time, into + `operator/build/kubernetes` and `operator/build/helm`. Change the Java classes or `application.yml`, never the output. +- **Tests**: `@QuarkusTest` integration tests against a k3s cluster and a real Garage node, both provided by Quarkus + Dev Services, so Docker is required. Prefer a test against the real Garage over a mock: what matters is how the + Admin API actually behaves. + +## 📐 Conventions + +- **Code style**: the shared AboutBits Checkstyle config, and JSpecify `@NullMarked` checked by NullAway. +- **Comments**: comment only what the code cannot say — a trap, a quirk of the backend, a contract. Do not restate the + code. Keep the density of the PostgreSQL operator. +- **Docs**: a change in behavior updates the matching page in `docs/`, and the `readme.md` if an administrator needs + to know about it. + +## 🛠️ Operational Commands + +The [`Makefile`](./Makefile) holds the common commands: + +- **Build**: `make install` +- **Run in dev mode**, against a throwaway k3s cluster and Garage node: `make run` +- **Run the tests**: `make test` +- **Run Checkstyle**: `./gradlew checkstyleMain checkstyleTest`. It needs GitHub Packages credentials, see the + [`readme.md`](./readme.md). +- **Lint Markdown**: `make lint` + +The Helm chart installation test fails against Helm 4; CI runs Helm 3. + +## 🧩 Automation Skills + +Before performing common tasks, check if a specialized skill exists in [`.agents/skills/`](./.agents/skills): + +| Skill | Use when... | +|-----------------|---------------------| +| `create-commit` | Making a git commit | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..21586f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright About Bits GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3e2c9ea --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +### SETUP + +init: + $(MAKE) install + +install: + ./gradlew --console=colored :operator:quarkusBuild + +### EXECUTION + +run: + ./gradlew --console=colored :operator:quarkusDev + +test: + ./gradlew --console=colored :operator:clean :operator:test --rerun-tasks + +### UTILITIES + +lint: + rumdl check . + +# Flag targets as phony, to tell `make` that these are no file targets +.PHONY: init install run test lint diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e70d892 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,158 @@ +import net.ltgt.gradle.errorprone.CheckSeverity +import net.ltgt.gradle.errorprone.errorprone +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent + +plugins { + idea + java + checkstyle + id("io.quarkus").apply(false) + alias(libs.plugins.axionReleasePlugin) + alias(libs.plugins.errorPronePlugin) +} + +description = "AboutBits Garage Operator" + +scmVersion { + checks { + aheadOfRemote = true + snapshotDependencies = false + uncommittedChanges = false + } + releaseBranchNames = setOf("main") + releaseOnlyOnReleaseBranches = true + versionCreator("simple") +} + +version = scmVersion.version + +allprojects { + group = "it.aboutbits.garage" + version = rootProject.version + + tasks.withType().configureEach { + dependsOn(":checkstyleExtractConfig") + + reports { + html.required = false + xml.required = false + } + } +} + +subprojects { + apply(plugin = "java") + apply(plugin = "checkstyle") + apply(plugin = rootProject.libs.plugins.errorPronePlugin.get().pluginId) + + java { + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 + + toolchain { + languageVersion = JavaLanguageVersion.of(JavaVersion.VERSION_25.majorVersion) + vendor = JvmVendorSpec.AMAZON + } + } + + val quarkusPlatformGroupId = providers.gradleProperty("quarkusPlatformGroupId").get() + val quarkusPlatformArtifactId = providers.gradleProperty("quarkusPlatformArtifactId").get() + val quarkusPlatformVersion = providers.gradleProperty("quarkusPlatformVersion").get() + + dependencies { + /** + * Quarkus + */ + // https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-bom + implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) + // https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-operator-sdk-bom + implementation(enforcedPlatform("${quarkusPlatformGroupId}:quarkus-operator-sdk-bom:${quarkusPlatformVersion}")) + + /** + * NullAway + */ + errorprone(rootProject.libs.errorProne) + errorprone(rootProject.libs.nullAway) + } + + tasks.withType().configureEach { + options.encoding = "UTF-8" + options.compilerArgs.add("-parameters") + + options.errorprone { + check("NullAway", CheckSeverity.ERROR) + check("RequireExplicitNullMarking", CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "it.aboutbits.garage") + option("NullAway:JSpecifyMode", "true") + } + } + + tasks.withType().configureEach { + useJUnitPlatform() + + systemProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager") + + testLogging { + exceptionFormat = TestExceptionFormat.FULL + + info { + showStandardStreams = !providers.environmentVariable("CI").isPresent + events( + *listOfNotNull( + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.FAILED, + TestLogEvent.STANDARD_ERROR, + if (!providers.environmentVariable("CI").isPresent) TestLogEvent.STANDARD_OUT else null + ).toTypedArray() + ) + } + } + + if (!project.hasProperty("createTestReports")) { + reports.html.required = false + reports.junitXml.required = false + } + + filter { + if (project.hasProperty("excludeTests")) { + val excludePatterns = project.property("excludeTests").toString().split(",") + excludePatterns.forEach { pattern -> + excludeTestsMatching(pattern.trim()) + } + } + } + } +} + +val checkstyleConfig = configurations.create("checkstyleConfig") { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + /** + * AboutBits Libraries + */ + checkstyleConfig(libs.checkstyleConfig) +} + +tasks.register("checkstyleExtractConfig") { + description = "Extracts the AboutBits Checkstyle configuration from the classpath." + group = JavaBasePlugin.CHECK_TASK_NAME + + from(zipTree(checkstyleConfig.singleFile)) { + include("checkstyle.xml", "checkstyle-suppressions.xml") + } + into(layout.projectDirectory.dir("config/checkstyle/")) +} + +checkstyle { + toolVersion = libs.versions.checkstyle.get() + isShowViolations = true + configFile = rootProject.file("config/checkstyle/checkstyle.xml") + configProperties = mapOf( + "suppressionFile" to rootProject.file("config/checkstyle/checkstyle-suppressions.xml") + ) +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..6ef87c8 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,14 @@ +# Gradle properties +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.logging.level=INFO + +# Quarkus +quarkusPluginId=io.quarkus +quarkusPluginVersion=3.34.6 +# https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-bom +quarkusPlatformGroupId=io.quarkus.platform +quarkusPlatformArtifactId=quarkus-bom +quarkusPlatformVersion=3.34.6 +systemProp.quarkus.analytics.disabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..49130dc --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,89 @@ +[versions] +## AboutBits Libraries ## +checkstyleConfig = "2.0.0-RC2" + +# Axion Release Plugin # +axionReleasePlugin = "1.21.1" + +## Libraries ## +jSpecify = "1.0.0" +lombok = "1.18.46" +quarkiverse-helm = "1.4.0" + +## Testing ## +assertj = "3.27.7" +awsSdk = "2.55.3" +checkstyle = "13.4.1" +datafaker = "2.5.4" +errorProne = "2.49.0" +errorPronePlugin = "5.1.0" +nullAway = "0.13.4" + +[plugins] +# https://github.com/allegro/axion-release-plugin +# https://axion-release-plugin.readthedocs.io/ +axionReleasePlugin = { id = "pl.allegro.tech.build.axion-release", version.ref = "axionReleasePlugin" } + +# https://github.com/tbroyer/gradle-errorprone-plugin +# https://mvnrepository.com/artifact/net.ltgt.errorprone/net.ltgt.errorprone.gradle.plugin +errorPronePlugin = { id = "net.ltgt.errorprone", version.ref = "errorPronePlugin" } + +[libraries] +## AboutBits Libraries ## + +# https://github.com/aboutbits/java-checkstyle-config +checkstyleConfig = { group = "it.aboutbits", name = "java-checkstyle-config", version.ref = "checkstyleConfig" } + +## Libraries ## + +# jSpecify # +# https://jspecify.dev/ +# https://github.com/jspecify/jspecify +# https://mvnrepository.com/artifact/org.jspecify/jspecify +jspecify = { group = "org.jspecify", name = "jspecify", version.ref = "jSpecify" } + +# Lombok # +# https://projectlombok.org/ +# https://github.com/projectlombok/lombok +# https://mvnrepository.com/artifact/org.projectlombok/lombok +lombok = { group = "org.projectlombok", name = "lombok", version.ref = "lombok" } + +# Quarkiverse Helm # +# https://docs.quarkiverse.io/quarkus-helm/dev/ +# https://github.com/quarkiverse/quarkus-helm +# https://mvnrepository.com/artifact/io.quarkiverse.helm/quarkus-helm +quarkiverse-helm = { group = "io.quarkiverse.helm", name = "quarkus-helm", version.ref = "quarkiverse-helm" } + +## Testing ## + +# https://assertj.github.io/ +# https://github.com/assertj/assertj +# https://mvnrepository.com/artifact/org.assertj/assertj-core +assertj = { group = "org.assertj", name = "assertj-core", version.ref = "assertj" } + +# AWS SDK for Java v2 # +# Test-only: used to prove that the credentials the operator writes actually work against the +# S3 API of the backend, not just that the Admin API reports a grant. +# https://github.com/aws/aws-sdk-java-v2 +# https://mvnrepository.com/artifact/software.amazon.awssdk/s3 +awsSdk-s3 = { group = "software.amazon.awssdk", name = "s3", version.ref = "awsSdk" } +awsSdk-urlConnectionClient = { group = "software.amazon.awssdk", name = "url-connection-client", version.ref = "awsSdk" } + +# https://checkstyle.org/ +# https://github.com/checkstyle/checkstyle +# https://mvnrepository.com/artifact/com.puppycrawl.tools/checkstyle +checkstyle = { group = "com.puppycrawl.tools", name = "checkstyle", version.ref = "checkstyle" } + +# https://datafaker.net/ +# https://github.com/datafaker-net/datafaker +# https://mvnrepository.com/artifact/net.datafaker/datafaker +datafaker = { group = "net.datafaker", name = "datafaker", version.ref = "datafaker" } + +# https://errorprone.info/ +# https://github.com/google/error-prone +# https://mvnrepository.com/artifact/com.google.errorprone/error_prone_core +errorProne = { group = "com.google.errorprone", name = "error_prone_core", version.ref = "errorProne" } + +# https://github.com/uber/NullAway +# https://mvnrepository.com/artifact/com.uber.nullaway/nullaway +nullAway = { group = "com.uber.nullaway", name = "nullaway", version.ref = "nullAway" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4a9cee2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=a9ecb5ac5c2ca40691e6527724d11d0b43b8c0a52825b77c09899f2a72d2d2bf +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-all.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..a8fed54 --- /dev/null +++ b/lombok.config @@ -0,0 +1,9 @@ +config.stopBubbling = true + +# Copy JSpecify annotations to generated code (constructors, getters, setters) +lombok.copyableAnnotations += org.jspecify.annotations.Nullable +lombok.copyableAnnotations += org.jspecify.annotations.NonNull +lombok.addNullAnnotations = jspecify + +# Required for NullAway +lombok.addLombokGeneratedAnnotation = true diff --git a/operator/build.gradle.kts b/operator/build.gradle.kts new file mode 100644 index 0000000..c5ffefd --- /dev/null +++ b/operator/build.gradle.kts @@ -0,0 +1,90 @@ +plugins { + id("io.quarkus") +} + +dependencies { + /** + * Quarkus Extensions + */ + implementation("io.quarkus:quarkus-arc") + implementation("io.quarkus:quarkus-config-yaml") + implementation("io.quarkus:quarkus-jackson") + implementation("io.quarkus:quarkus-kubernetes-client") + implementation("io.quarkus:quarkus-logging-json") + implementation("io.quarkus:quarkus-micrometer") + implementation("io.quarkus:quarkus-micrometer-registry-prometheus") + implementation("io.quarkus:quarkus-rest-client-jackson") + implementation("io.quarkus:quarkus-smallrye-health") + + /** + * Fabric8 Kubernetes Client + */ + implementation("io.fabric8:generator-annotations") + implementation("io.fabric8:crd-generator-api-v2") + + /** + * JSpecify + */ + implementation(libs.jspecify) + + /** + * Lombok + */ + compileOnly(libs.lombok) + annotationProcessor(libs.lombok) + testImplementation(libs.lombok) + testAnnotationProcessor(libs.lombok) + + /** + * Quarkiverse Helm + */ + implementation(libs.quarkiverse.helm) + + /** + * Quarkiverse Operator SDK + */ + implementation("io.quarkiverse.operatorsdk:quarkus-operator-sdk") + implementation("io.quarkiverse.operatorsdk:quarkus-operator-sdk-annotations") + + /** + * Testing + */ + testImplementation("io.quarkus:quarkus-junit") + testImplementation("io.quarkus:quarkus-junit-mockito") + testImplementation("org.awaitility:awaitility") + testImplementation(libs.assertj) + testImplementation(libs.awsSdk.s3) + testImplementation(libs.awsSdk.urlConnectionClient) + testImplementation(libs.datafaker) +} + +tasks.quarkusAppPartsBuild { + doNotTrackState("Always execute Gradle task quarkusAppPartsBuild to generate the K8s deploy manifest kubernetes.yml, the CRDs, and to publish the Helm chart") +} + +val mockitoAgentProvider = configurations.named("testRuntimeClasspath").map { classpath -> + classpath.find { it.name.contains("mockito-core") } +} + +tasks.withType().configureEach { + // Required for the HelmTest + dependsOn(tasks.quarkusAppPartsBuild) + + jvmArgumentProviders.add(MockitoArgumentProvider(mockitoAgentProvider)) +} + +class MockitoArgumentProvider( + @get:Optional + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + val agentProvider: Provider +) : CommandLineArgumentProvider { + override fun asArguments(): Iterable { + val agentFile = agentProvider.orNull + return if (agentFile != null) { + listOf("-javaagent:${agentFile.absolutePath}") + } else { + emptyList() + } + } +} diff --git a/operator/src/main/docker/Dockerfile.jvm b/operator/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..43d4dba --- /dev/null +++ b/operator/src/main/docker/Dockerfile.jvm @@ -0,0 +1,97 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./gradlew build +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/garage-operator-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/garage-operator-jvm +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005. +# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005 +# when running the container +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/garage-operator-jvm +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override +# the default JVM options, use `JAVA_OPTS_APPEND` to append options +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +# https://catalog.redhat.com/en/software/containers/ubi9/openjdk-25-runtime/69204990c46419100ce30a5b +FROM registry.access.redhat.com/ubi9/openjdk-25-runtime:1.24 + +ENV LANGUAGE='en_US:en' + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 build/quarkus-app/*.jar /deployments/ +COPY --chown=185 build/quarkus-app/app/ /deployments/app/ +COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 8080 +USER 185 +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] diff --git a/operator/src/main/helm/LICENSE b/operator/src/main/helm/LICENSE new file mode 100644 index 0000000..21586f5 --- /dev/null +++ b/operator/src/main/helm/LICENSE @@ -0,0 +1,7 @@ +Copyright About Bits GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/operator/src/main/helm/values.yaml b/operator/src/main/helm/values.yaml new file mode 100644 index 0000000..1588cb1 --- /dev/null +++ b/operator/src/main/helm/values.yaml @@ -0,0 +1,22 @@ +--- +# This file overrides the default values that the quarkus-helm extension generates. +# +# Why it exists: a list field of the operator Deployment becomes a Helm value only if the key +# already exists in `src/main/kubernetes/kubernetes.yml`. An empty list `[]` does not survive +# there. The fabric8 model marks `PodSpec.imagePullSecrets`, `PodSpec.volumes` and +# `Container.volumeMounts` with `@JsonInclude(NON_EMPTY)`. A list with one null element does +# survive, but the generated default is then unusable, so this file replaces it with a real +# empty list. +# +# The unusable default takes one of two shapes, and the path of the value decides which: +# - A plain path, such as `spec.template.spec.volumes`, produces `- {}`, a list that holds +# one empty object. A user who copies that default and appends an entry gets invalid YAML. +# - A container-filtered path, such as +# `spec.template.spec.containers.(name == garage-operator).volumeMounts`, produces +# `{}`, an object. That shape also contradicts the `type: array` of `values.schema.json`. +# +# See https://github.com/quarkiverse/quarkus-helm/issues/453 +app: + imagePullSecrets: [] + volumes: [] + volumeMounts: [] diff --git a/operator/src/main/java/it/aboutbits/garage/core/CRPhase.java b/operator/src/main/java/it/aboutbits/garage/core/CRPhase.java new file mode 100644 index 0000000..da94a4b --- /dev/null +++ b/operator/src/main/java/it/aboutbits/garage/core/CRPhase.java @@ -0,0 +1,11 @@ +package it.aboutbits.garage.core; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public enum CRPhase { + PENDING, + READY, + ERROR, + DELETING +} diff --git a/operator/src/main/java/it/aboutbits/garage/core/CRStatus.java b/operator/src/main/java/it/aboutbits/garage/core/CRStatus.java new file mode 100644 index 0000000..0a60241 --- /dev/null +++ b/operator/src/main/java/it/aboutbits/garage/core/CRStatus.java @@ -0,0 +1,56 @@ +package it.aboutbits.garage.core; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +/// Status Object for the Custom Resources. +/// +/// This object captures the current state of a Custom Resource as observed by the reconciler. +@Getter +@Setter +@Accessors(chain = true) +@NullMarked +public class CRStatus { + /// The Custom Resource name (may differ from metadata.name). + private @Nullable String name = null; + + /// Current lifecycle phase of the Custom Resource. + @Setter(AccessLevel.NONE) + private CRPhase phase = CRPhase.PENDING; + + /// Human-readable message providing details about the current state. + private @Nullable String message = null; + + /// Last time the condition was probed/updated. + private @Nullable OffsetDateTime lastProbeTime = null; + + /// Last time the condition transitioned from one status to another. + @Setter(AccessLevel.NONE) + private @Nullable OffsetDateTime lastPhaseTransitionTime = null; + + /// Observed resource generation that the controller acted upon. + private long observedGeneration = 0; + + /// Update the current phase. When the phase changes, the [#lastPhaseTransitionTime] + /// is updated to the current UTC time. + /// + /// @param newPhase the new phase + /// @return this status instance + public CRStatus setPhase(CRPhase newPhase) { + if (this.phase == newPhase) { + return this; + } + + this.phase = newPhase; + this.lastPhaseTransitionTime = OffsetDateTime.now(ZoneOffset.UTC); + + return this; + } +} diff --git a/operator/src/main/java/it/aboutbits/garage/core/Named.java b/operator/src/main/java/it/aboutbits/garage/core/Named.java new file mode 100644 index 0000000..5a58697 --- /dev/null +++ b/operator/src/main/java/it/aboutbits/garage/core/Named.java @@ -0,0 +1,10 @@ +package it.aboutbits.garage.core; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public interface Named { + @JsonIgnore + String getName(); +} diff --git a/operator/src/main/java/it/aboutbits/garage/core/Quantities.java b/operator/src/main/java/it/aboutbits/garage/core/Quantities.java new file mode 100644 index 0000000..c59caec --- /dev/null +++ b/operator/src/main/java/it/aboutbits/garage/core/Quantities.java @@ -0,0 +1,35 @@ +package it.aboutbits.garage.core; + +import io.fabric8.kubernetes.api.model.Quantity; +import org.jspecify.annotations.NullMarked; + +/// Converts Kubernetes quantity strings (`20Gi`, `1G`) into bytes. +@NullMarked +public final class Quantities { + public static long toBytes( + String value, + String field + ) { + long bytes; + + try { + bytes = Quantity.getAmountInBytes(Quantity.parse(value)).longValueExact(); + } catch (ArithmeticException | IllegalArgumentException e) { + throw new IllegalArgumentException( + "The %s is not a valid Kubernetes quantity [%s=%s]".formatted(field, field, value), + e + ); + } + + if (bytes <= 0) { + throw new IllegalArgumentException( + "The %s must be greater than zero [%s=%s]".formatted(field, field, value) + ); + } + + return bytes; + } + + private Quantities() { + } +} diff --git a/operator/src/main/java/it/aboutbits/garage/core/ResourceRef.java b/operator/src/main/java/it/aboutbits/garage/core/ResourceRef.java new file mode 100644 index 0000000..5aae64c --- /dev/null +++ b/operator/src/main/java/it/aboutbits/garage/core/ResourceRef.java @@ -0,0 +1,50 @@ +package it.aboutbits.garage.core; + +import io.fabric8.crdv2.generator.v1.SchemaCustomizer; +import io.fabric8.generator.annotation.Max; +import io.fabric8.generator.annotation.Required; +import io.fabric8.generator.annotation.ValidationRule; +import it.aboutbits.garage.core.schema_customizer.KubernetesNameCustomizer; +import lombok.Getter; +import lombok.Setter; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/// A reference to a Kubernetes resource identified by [#namespace] (optional) and [#name]. +/// +/// This class is used wherever a CRD spec needs to point to another Kubernetes resource. +/// +/// ### Namespace resolution +/// +/// The [#namespace] field is **nullable**. When it is `null` (or omitted +/// in the CR manifest), the operator resolves the target resource in the +/// **same namespace as the CR that contains the reference**. This convention +/// keeps single-namespace deployments simple — users only need to set +/// `namespace` when referring to a resource in a *different* namespace. +/// +/// | `namespace` value | Resolved namespace | +/// |-------------------|-----------------------------------------------------| +/// | non-null | the explicit namespace | +/// | `null` (omitted) | the namespace of the CR that owns this reference | +@Getter +@Setter +// Scoped to the two fields that actually hold Kubernetes object names rather than left blank +// (which would mean "every string property"), because this customizer is inherited by subclasses +// such as SecretKeyRef, whose additional fields follow different naming rules. +@SchemaCustomizer(value = KubernetesNameCustomizer.class, input = "name,namespace") +@NullMarked +public class ResourceRef { + /// The namespace of the referenced Kubernetes resource. + /// If `null`, defaults to the namespace of the CR that defines this reference. + @io.fabric8.generator.annotation.Nullable + @Max(63) + private @Nullable String namespace; + + @Required + @Max(63) + @ValidationRule( + value = "self.trim().size() > 0", + message = "The name must not be empty." + ) + private String name = ""; +} diff --git a/operator/src/main/java/it/aboutbits/garage/core/SecretKeyRef.java b/operator/src/main/java/it/aboutbits/garage/core/SecretKeyRef.java new file mode 100644 index 0000000..8143fd3 --- /dev/null +++ b/operator/src/main/java/it/aboutbits/garage/core/SecretKeyRef.java @@ -0,0 +1,22 @@ +package it.aboutbits.garage.core; + +import io.fabric8.generator.annotation.Max; +import io.fabric8.generator.annotation.Required; +import io.fabric8.generator.annotation.ValidationRule; +import lombok.Getter; +import lombok.Setter; +import org.jspecify.annotations.NullMarked; + +/// [#key] is deliberately not validated as an object name: Secret data keys are more permissive (`admin_token`). +@Getter +@Setter +@NullMarked +public class SecretKeyRef extends ResourceRef { + @Required + @Max(253) + @ValidationRule( + value = "self.trim().size() > 0", + message = "The Secret key must not be empty." + ) + private String key = ""; +} diff --git a/operator/src/main/java/it/aboutbits/garage/core/schema_customizer/HostCustomizer.java b/operator/src/main/java/it/aboutbits/garage/core/schema_customizer/HostCustomizer.java new file mode 100644 index 0000000..a54eebf --- /dev/null +++ b/operator/src/main/java/it/aboutbits/garage/core/schema_customizer/HostCustomizer.java @@ -0,0 +1,98 @@ +package it.aboutbits.garage.core.schema_customizer; + +import io.fabric8.crdv2.generator.v1.SchemaCustomizer; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; +import org.jspecify.annotations.NullMarked; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/// A [SchemaCustomizer.Customizer] that sets the `format` of string properties +/// to `{"anyOf":[{"format":"hostname"},{"format":"ipv4"},{"format":"ipv6"}]}` +/// in the generated CRD JSON Schema. +/// +/// This customizer is intended to be used with the +/// [@SchemaCustomizer][SchemaCustomizer] annotation on a class whose properties +/// should be validated to valid hosts defined. +/// +/// ### Behavior +/// +/// - If `input` is **blank** (the default), the `"hostname"` format +/// is applied to **all** string properties of the annotated class. +/// - If `input` contains a **comma-separated list** of field names, +/// the format is applied **only** to the specified properties. +/// +/// ### Usage examples +/// +/// **Apply to all string properties:** +/// +/// ```java +/// @SchemaCustomizer(value = HostCustomizer.class) +/// public class S3ConnectionSpec { +/// private String host = ""; // gets custom format +/// private String anotherHost = ""; // gets custom format +/// } +/// ``` +/// +/// **Apply to specific properties only:** +/// +/// ```java +/// @SchemaCustomizer(value = HostCustomizer.class, input = "host,anotherHost") +/// public class S3ConnectionSpec { +/// private String host = ""; // gets custom format +/// private String anotherHost = ""; // gets custom format +/// private String unchangedHost = ""; // unchanged +/// } +/// ``` +/// +/// @see SchemaCustomizer +/// @see SchemaCustomizer.Customizer +@NullMarked +public class HostCustomizer implements SchemaCustomizer.Customizer { + @Override + public JSONSchemaProps apply( + JSONSchemaProps jsonSchemaProps, + String input, + KubernetesSerialization kubernetesSerialization + ) { + var properties = jsonSchemaProps.getProperties(); + if (properties == null) { + return jsonSchemaProps; + } + + var targetFields = input.isBlank() + ? Set.of() + : Arrays.stream(input.split(",")) + .map(String::trim) + .collect(Collectors.toSet()); + + for (var entry : properties.entrySet()) { + var prop = entry.getValue(); + if ("string".equals(prop.getType()) + && (targetFields.isEmpty() || targetFields.contains(entry.getKey())) + ) { + prop.setFormat(null); + + var hostnameProp = new JSONSchemaProps(); + hostnameProp.setFormat("hostname"); + + var ipv4Prop = new JSONSchemaProps(); + ipv4Prop.setFormat("ipv4"); + + var ipv6Prop = new JSONSchemaProps(); + ipv6Prop.setFormat("ipv6"); + + prop.setAnyOf(List.of( + hostnameProp, + ipv4Prop, + ipv6Prop + )); + } + } + + return jsonSchemaProps; + } +} diff --git a/operator/src/main/java/it/aboutbits/garage/core/schema_customizer/KubernetesNameCustomizer.java b/operator/src/main/java/it/aboutbits/garage/core/schema_customizer/KubernetesNameCustomizer.java new file mode 100644 index 0000000..ac63c7a --- /dev/null +++ b/operator/src/main/java/it/aboutbits/garage/core/schema_customizer/KubernetesNameCustomizer.java @@ -0,0 +1,90 @@ +package it.aboutbits.garage.core.schema_customizer; + +import io.fabric8.crdv2.generator.v1.SchemaCustomizer; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; +import org.jspecify.annotations.NullMarked; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/// A [SchemaCustomizer.Customizer] that adds a Kubernetes name validation +/// `pattern` (RFC 1123 DNS label) to string properties in the generated CRD +/// JSON Schema. +/// +/// The pattern enforces: +/// - Contain at most 63 characters +/// - Contain only lowercase alphanumeric characters or '-' +/// - Start with an alphabetic character +/// - End with an alphanumeric character +/// +/// This customizer is intended to be used with the +/// [@SchemaCustomizer][SchemaCustomizer] annotation on a class whose string +/// properties represent Kubernetes resource names. +/// +/// ### Behavior +/// +/// - If `input` is **blank** (the default), the `"hostname"` format +/// is applied to **all** string properties of the annotated class. +/// - If `input` contains a **comma-separated list** of field names, +/// the format is applied **only** to the specified properties. +/// +/// ### Usage examples +/// +/// **Apply to all string properties:** +/// +/// ```java +/// @SchemaCustomizer(KubernetesNameCustomizer.class) +/// public class ResourceRef { +/// private String name = ""; // gets pattern: Kubernetes name regex +/// private String namespace; // gets pattern: Kubernetes name regex +/// } +/// ``` +/// +/// **Apply to specific properties only:** +/// +/// ```java +/// @SchemaCustomizer(value = KubernetesNameCustomizer.class, input = "name,anotherName") +/// public class ResourceRef { +/// private String name = ""; // gets pattern: Kubernetes name regex +/// private String anotherName = ""; // gets pattern: Kubernetes name regex +/// private String namespace; // unchanged +/// } +/// ``` +/// +/// @see SchemaCustomizer +/// @see SchemaCustomizer.Customizer +@NullMarked +public class KubernetesNameCustomizer implements SchemaCustomizer.Customizer { + static final String KUBERNETES_NAME_PATTERN = "^[a-z]([a-z0-9\\-]{0,61}[a-z0-9])?$"; + + @Override + public JSONSchemaProps apply( + JSONSchemaProps jsonSchemaProps, + String input, + KubernetesSerialization kubernetesSerialization + ) { + var properties = jsonSchemaProps.getProperties(); + if (properties == null) { + return jsonSchemaProps; + } + + var targetFields = input.isBlank() + ? Set.of() + : Arrays.stream(input.split(",")) + .map(String::trim) + .collect(Collectors.toSet()); + + for (var entry : properties.entrySet()) { + var prop = entry.getValue(); + if ("string".equals(prop.getType()) + && (targetFields.isEmpty() || targetFields.contains(entry.getKey())) + ) { + prop.setPattern(KUBERNETES_NAME_PATTERN); + } + } + + return jsonSchemaProps; + } +} diff --git a/operator/src/main/kubernetes/kubernetes.yml b/operator/src/main/kubernetes/kubernetes.yml new file mode 100644 index 0000000..3c71d3b --- /dev/null +++ b/operator/src/main/kubernetes/kubernetes.yml @@ -0,0 +1,29 @@ +--- +# See https://quarkus.io/guides/deploying-to-kubernetes#using-existing-resources +apiVersion: apps/v1 +kind: Deployment +metadata: + # The name must match `quarkus.kubernetes.name`, otherwise Dekorate adds a second Deployment. + # `HelmTest` has a test that makes sure this never drifts apart. + name: garage-operator +spec: + template: + spec: + affinity: {} + # Satisfies the `restricted` Pod Security Standard. The image runs as the non-root user 185. + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + # The `[~]` placeholders are required, see operator/src/main/helm/values.yaml for the reason. + imagePullSecrets: [~] + volumes: [~] + containers: + # The name must match `quarkus.kubernetes.name`, otherwise Dekorate adds a second container. + - name: garage-operator + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: [~] diff --git a/operator/src/main/resources/application-dev.yml b/operator/src/main/resources/application-dev.yml new file mode 100644 index 0000000..510348e --- /dev/null +++ b/operator/src/main/resources/application-dev.yml @@ -0,0 +1,4 @@ +quarkus: + operator-sdk: + activate-leader-election-for-profiles: + - dev diff --git a/operator/src/main/resources/application-test.yml b/operator/src/main/resources/application-test.yml new file mode 100644 index 0000000..c16d3b8 --- /dev/null +++ b/operator/src/main/resources/application-test.yml @@ -0,0 +1,4 @@ +quarkus: + operator-sdk: + activate-leader-election-for-profiles: + - test diff --git a/operator/src/main/resources/application.yml b/operator/src/main/resources/application.yml new file mode 100644 index 0000000..c6f65ba --- /dev/null +++ b/operator/src/main/resources/application.yml @@ -0,0 +1,212 @@ +quarkus: + console: + color: true + log: + console: + json: + enabled: false + log-format: ECS + kubernetes-client: + devservices: + enabled: true + # To not use our default kubeconfig from the home directory, which could lead to + # potential damage if we had the permissions to install the CRD in an existing configured cluster context. + # By setting this to true, Quarkus will use a temporary kubeconfig that will be removed after the test. + override-kubeconfig: true + flavor: k3s + # See https://github.com/dajudge/kindcontainer/blob/master/k8s-versions.json + api-version: 1.34.1 + live-reload: + instrumentation: true + micrometer: + enabled: true + operator-sdk: + crd: + generate: true + # NOTE that this option is only considered when *not* in production mode + # as applying the CRD to a production cluster could be dangerous if done automatically. + apply: true + # Whether controllers should only process events if the associated resource generation + # has increased since the last reconciliation, otherwise will process all events. + generation-aware: true + test: + hang-detection-timeout: PT1M + + # Config for the generated Helm chart # + # Container Image config for Kubernetes Helm # + container-image: + registry: ghcr.io + group: aboutbits + name: garage-operator + tag: ${quarkus.application.version} + helm: + app-version: ${quarkus.application.version} + type: application + name: ${quarkus.kubernetes.name} + description: AboutBits Garage Operator Helm Chart + # Keep the map-system-properties flag below on false, else it will map all ${ENV_VARS} we define in the application.yml or application-prod.yml files. + # In Kubernetes the env entries take precedence over envFrom entries + map-system-properties: false + home: https://github.com/aboutbits/garage-operator + sources: + - https://github.com/aboutbits/garage-operator + annotations: + "catalog.cattle.io/os": linux + keywords: + - aboutbits + - s3 + - garage + - operator + maintainers: + "AboutBits": + name: AboutBits + email: info@aboutbits.it + url: https://aboutbits.it/ + create-tar-file: true + extension: tgz + values: + replicas: + property: replicas + value-as-int: 1 + description: Keep this at 1. The operator has no leader election, so more replicas would reconcile the same resources concurrently. + paths: + - (kind == Deployment).spec.replicas + image-pull-policy: + property: imagePullPolicy + value: IfNotPresent + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).imagePullPolicy + image-pull-secrets: + property: imagePullSecrets + paths: + - (kind == Deployment).spec.template.spec.imagePullSecrets + expression: "{{- toYaml (.Values.app.imagePullSecrets | default list) | nindent 8 }}" + resource-requests-cpu: + property: resources.requests.cpu + value: ${quarkus.kubernetes.resources.requests.cpu} + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).resources.requests.cpu + resource-requests-memory: + property: resources.requests.memory + value: ${quarkus.kubernetes.resources.requests.memory} + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).resources.requests.memory + resource-limits-memory: + property: resources.limits.memory + value: ${quarkus.kubernetes.resources.limits.memory} + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).resources.limits.memory + affinity: + property: affinity + value-as-map: {} + paths: + - (kind == Deployment).spec.template.spec.affinity + description: Kubernetes affinity configuration for Pod scheduling + volumes: + property: volumes + paths: + - (kind == Deployment).spec.template.spec.volumes + expression: "{{- toYaml (.Values.app.volumes | default list) | nindent 8 }}" + volume-mounts: + property: volumeMounts + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).volumeMounts + expression: "{{- toYaml (.Values.app.volumeMounts | default list) | nindent 12 }}" + console-color: + property: envs.QUARKUS_CONSOLE_COLOR + value-as-bool: ${quarkus.console.color} + description: If color should be enabled or disabled. If this is unset, then an attempt will be made to guess if the terminal supports color. + log-console-json-enabled: + property: envs.QUARKUS_LOG_CONSOLE_JSON_ENABLED + value-as-bool: ${quarkus.log.console.json.enabled} + description: Determine whether to enable the JSON console formatting extension, which disables "normal" console formatting. + log-console-json-log-format: + property: envs.QUARKUS_LOG_CONSOLE_JSON_LOG_FORMAT + value: ${quarkus.log.console.json.log-format} + description: Specify the format of the produced JSON. Supported values are "DEFAULT", "ECS", and "GCP". + values-schema: + properties: + # The type must be set explicitly for every non-scalar value, because the generated + # schema otherwise falls back to `string`. + # + # A value that `src/main/helm/values.yaml` provides also loses the `description` of its + # `quarkus.helm.values` entry, so the description belongs here instead. + "affinity": + name: app.affinity + type: object + "imagePullSecrets": + name: app.imagePullSecrets + type: array + description: Kubernetes image pull secrets to use if the OCI image is hosted on a private registry + "volumes": + name: app.volumes + type: array + description: Additional volumes for the operator Pod, for example a Secret volume or a Secrets Store CSI volume + "volumeMounts": + name: app.volumeMounts + type: array + description: Additional volume mounts for the operator container + expressions: + release-name-labels: + expression: "{{ .Release.Name }}" + path: metadata.labels.'app.kubernetes.io/name' + release-name-service-selector: + expression: "{{ .Release.Name }}" + path: (kind == Service).spec.selector.'app.kubernetes.io/name' + release-name-deployment-match-labels: + expression: "{{ .Release.Name }}" + path: (kind == Deployment).spec.selector.matchLabels.'app.kubernetes.io/name' + release-name-deployment-labels: + expression: "{{ .Release.Name }}" + path: (kind == Deployment).spec.template.metadata.labels.'app.kubernetes.io/name' + affinity: + expression: "{{- toYaml (.Values.app.affinity | default dict) | nindent 8 }}" + path: (kind == Deployment).spec.template.spec.affinity + kubernetes: + name: garage-operator + version: ${quarkus.application.version} + add-version-to-label-selectors: false + image-pull-policy: IfNotPresent + replicas: 1 + # There is no leader election, so two operator pods must never run at the same time — a + # rolling update would overlap them, and both would reconcile the same resources. Recreate + # stops the old pod before the new one starts. + strategy: Recreate + annotations: + "app.kubernetes.io/version": ${quarkus.application.version} + resources: + requests: + cpu: 50m + memory: 300Mi + limits: + memory: 512Mi + prometheus: + generate-service-monitor: false + startup-probe: + http-action-port-name: http + initial-delay: PT2S + period: PT10S + timeout: PT3S + success-threshold: 1 + failure-threshold: 3 + readiness-probe: + http-action-port-name: http + initial-delay: PT0S + period: PT5S + timeout: PT3S + success-threshold: 1 + failure-threshold: 3 + liveness-probe: + http-action-port-name: http + initial-delay: PT5S + period: PT10S + timeout: PT3S + success-threshold: 1 + failure-threshold: 3 + env: + fields: + KUBERNETES_NODE_NAME: spec.nodeName + vars: + QUARKUS_CONSOLE_COLOR: ${quarkus.console.color} + QUARKUS_LOG_CONSOLE_JSON_ENABLED: ${quarkus.log.console.json.enabled} + QUARKUS_LOG_CONSOLE_JSON_LOG_FORMAT: ${quarkus.log.console.json.log-format} diff --git a/operator/src/test/java/it/aboutbits/garage/_support/valuesource/BlankSource.java b/operator/src/test/java/it/aboutbits/garage/_support/valuesource/BlankSource.java new file mode 100644 index 0000000..cf86d17 --- /dev/null +++ b/operator/src/test/java/it/aboutbits/garage/_support/valuesource/BlankSource.java @@ -0,0 +1,18 @@ +package it.aboutbits.garage._support.valuesource; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.params.provider.ValueSource; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ValueSource(strings = {"", " ", " ", "\t", "\r", "\n", "\r\n", "\f", "\u000B"}) +@NullMarked +public @interface BlankSource { +} diff --git a/operator/src/test/java/it/aboutbits/garage/core/CRStatusTest.java b/operator/src/test/java/it/aboutbits/garage/core/CRStatusTest.java new file mode 100644 index 0000000..03e3ea3 --- /dev/null +++ b/operator/src/test/java/it/aboutbits/garage/core/CRStatusTest.java @@ -0,0 +1,59 @@ +package it.aboutbits.garage.core; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@NullMarked +class CRStatusTest { + @Nested + class SetPhase { + @Test + @DisplayName("A new status should start out pending without a transition time") + void newStatus_isPending() { + var status = new CRStatus(); + + assertThat(status.getPhase()).isEqualTo(CRPhase.PENDING); + assertThat(status.getLastPhaseTransitionTime()).isNull(); + } + + @Test + @DisplayName("Changing the phase should stamp the transition time") + void phaseChange_stampsTransitionTime() { + var status = new CRStatus(); + + status.setPhase(CRPhase.READY); + + assertThat(status.getPhase()).isEqualTo(CRPhase.READY); + assertThat(status.getLastPhaseTransitionTime()).isNotNull(); + } + + @Test + @DisplayName("Setting the same phase again should not touch the transition time") + void samePhase_keepsTransitionTime() { + var status = new CRStatus(); + + status.setPhase(CRPhase.READY); + + var transitionTime = status.getLastPhaseTransitionTime(); + + status.setPhase(CRPhase.READY); + + assertThat(status.getLastPhaseTransitionTime()).isEqualTo(transitionTime); + } + + @Test + @DisplayName("Setting the phase should be chainable with the remaining status setters") + void setPhase_isChainable() { + var status = new CRStatus() + .setPhase(CRPhase.ERROR) + .setMessage("Something went wrong"); + + assertThat(status.getPhase()).isEqualTo(CRPhase.ERROR); + assertThat(status.getMessage()).isEqualTo("Something went wrong"); + } + } +} diff --git a/operator/src/test/java/it/aboutbits/garage/core/QuantitiesTest.java b/operator/src/test/java/it/aboutbits/garage/core/QuantitiesTest.java new file mode 100644 index 0000000..504d8ee --- /dev/null +++ b/operator/src/test/java/it/aboutbits/garage/core/QuantitiesTest.java @@ -0,0 +1,40 @@ +package it.aboutbits.garage.core; + +import it.aboutbits.garage._support.valuesource.BlankSource; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@NullMarked +class QuantitiesTest { + @ParameterizedTest + @CsvSource({ + "20Gi, 21474836480", + "1Gi, 1073741824", + "500Mi, 524288000", + "1G, 1000000000", + "1024, 1024" + }) + @DisplayName("Kubernetes quantities should be converted to bytes") + void quantity_isConvertedToBytes( + String value, + long expectedBytes + ) { + assertThat(Quantities.toBytes(value, "capacity")).isEqualTo(expectedBytes); + } + + @ParameterizedTest + @BlankSource + @ValueSource(strings = {"twenty", "20Gb", "-1Gi", "0"}) + @DisplayName("An unusable quantity should be rejected, naming the field it came from") + void invalidQuantity_isRejected(String value) { + assertThatThrownBy(() -> Quantities.toBytes(value, "quotas maxSize")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("quotas maxSize"); + } +} diff --git a/operator/src/test/java/it/aboutbits/garage/helm/HelmTest.java b/operator/src/test/java/it/aboutbits/garage/helm/HelmTest.java new file mode 100644 index 0000000..b9f68e6 --- /dev/null +++ b/operator/src/test/java/it/aboutbits/garage/helm/HelmTest.java @@ -0,0 +1,455 @@ +package it.aboutbits.garage.helm; + +import com.fasterxml.jackson.databind.JsonNode; +import io.fabric8.kubernetes.api.model.ConfigBuilder; +import io.fabric8.kubernetes.api.model.LocalObjectReference; +import io.fabric8.kubernetes.api.model.Volume; +import io.fabric8.kubernetes.api.model.VolumeMount; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; +import io.fabric8.kubernetes.client.utils.Serialization; +import io.quarkus.test.junit.QuarkusTest; +import io.smallrye.common.process.ProcessBuilder; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/// See https://github.com/quarkiverse/quarkus-operator-sdk/blob/7.7.4/samples/exposedapp/src/test/java/io/halkyon/HelmDeploymentE2EIT.java +@Slf4j +@QuarkusTest +@NullMarked +class HelmTest { + private static final String ENV_VAR_KUBECONFIG = "KUBECONFIG"; + private static final KubernetesSerialization KUBERNETES_SERIALIZATION = new KubernetesSerialization(); + + /// The Pod and Container list fields that the chart exposes as free-form Helm values. + private static final List LIST_VALUES = List.of( + "imagePullSecrets", + "volumes", + "volumeMounts" + ); + + private static final String CRD_GROUP = "garage.aboutbits.it"; + /// Singular name (used for the reconciler's role binding) to plural name (used for the CRD + /// file). Spelled out rather than derived, because `bucketaccess` → `bucketaccesses` is not a + /// plain `+ "s"`. + private static final Map CRD_NAMES = Map.of(); + + private final String chartName; + /// Dekorate uses this value for the Deployment name and for the container name. + private final String kubernetesName; + private final String rootValuesAlias; + private final KubernetesClient kubernetesClient; + + HelmTest( + KubernetesClient kubernetesClient, + @ConfigProperty(name = "quarkus.helm.name") String chartName, + @ConfigProperty(name = "quarkus.kubernetes.name") String kubernetesName, + @ConfigProperty(name = "quarkus.helm.values-root-alias", defaultValue = "app") String rootValuesAlias + ) { + this.kubernetesClient = kubernetesClient; + this.chartName = chartName; + this.kubernetesName = kubernetesName; + this.rootValuesAlias = rootValuesAlias; + } + + @SuppressWarnings("checkstyle:MethodLength") + @Test + @DisplayName("When the Helm chart is installed, the operator deployment should be created") + void helmInstall_createsDeployment() throws IOException { + // given + var chartPath = chartPath(); + + assertThat(chartPath) + .withFailMessage("Helm chart not found at %s. Ensure that the chart is generated before running this test.", chartPath) + .exists(); + + // 1. Verify files exist and contain expected data + // ./Chart.yaml + @SuppressWarnings("unchecked") + Map chartMetadata = KUBERNETES_SERIALIZATION.unmarshal( + Files.newInputStream(chartPath.resolve("Chart.yaml")), + Map.class + ); + + assertThat(chartMetadata.get("name")).isEqualTo(chartName); + + // ./values.yaml + @SuppressWarnings("unchecked") + Map values = KUBERNETES_SERIALIZATION.unmarshal( + Files.newInputStream(chartPath.resolve("values.yaml")), + Map.class + ); + + assertThat(values).containsKey(rootValuesAlias); + + @SuppressWarnings("unchecked") + var appValues = (Map) values.get(rootValuesAlias); + + Objects.requireNonNull(appValues, "appValues should not be null"); + assertThat(appValues.get("image")).isNotNull(); + + // The list values must default to a real empty list, not to `- {}`. + // `operator/src/main/helm/values.yaml` provides these defaults. + for (var listValue : LIST_VALUES) { + assertThat(appValues.get(listValue)) + .withFailMessage("app.%s should default to an empty list, but was %s", listValue, appValues.get(listValue)) + .isEqualTo(List.of()); + } + + assertThat(chartPath.resolve("LICENSE")).exists(); + assertThat(chartPath.resolve("README.md")).exists(); + assertThat(chartPath.resolve("values.schema.json")).exists(); + + // ./values.schema.json + // The type must be declared for every list value, otherwise the generated schema + // falls back to `string` and `helm install` rejects a list. + var valuesSchema = KUBERNETES_SERIALIZATION.unmarshal( + Files.newInputStream(chartPath.resolve("values.schema.json")), + JsonNode.class + ); + + for (var listValue : LIST_VALUES) { + var schemaProperty = valuesSchema.at("/properties/%s/properties/%s".formatted( + rootValuesAlias, + listValue + )); + + assertThat(schemaProperty.path("type").asText()) + .withFailMessage("app.%s should be typed as an array in values.schema.json", listValue) + .isEqualTo("array"); + + // A value that `src/main/helm/values.yaml` provides loses the description of its + // `quarkus.helm.values` entry, so the description has to come from the schema. + assertThat(schemaProperty.path("description").asText()) + .withFailMessage("app.%s should have a description in values.schema.json", listValue) + .isNotBlank(); + } + + // ./crds/ + for (var crdPlural : CRD_NAMES.values()) { + assertThat(chartPath.resolve("crds/%s.%s-v1.yml".formatted( + crdPlural, + CRD_GROUP + ))).exists(); + } + + // ./templates/ + assertThat(chartPath.resolve("templates/clusterrole.yaml")).exists(); + assertThat(chartPath.resolve("templates/deployment.yaml")).exists(); + assertThat(chartPath.resolve("templates/rolebinding.yaml")).exists(); + assertThat(chartPath.resolve("templates/service.yaml")).exists(); + assertThat(chartPath.resolve("templates/serviceaccount.yaml")).exists(); + assertThat(chartPath.resolve("templates/validating-clusterrolebinding.yaml")).exists(); + + // The indent of each expression has to match the depth of its field in the Deployment. + // A wrong `nindent` produces invalid YAML as soon as a user sets the value. + assertThat(chartPath.resolve("templates/deployment.yaml")) + .content() + .contains("imagePullSecrets: {{- toYaml (.Values.app.imagePullSecrets | default list) | nindent 8 }}") + .contains("volumes: {{- toYaml (.Values.app.volumes | default list) | nindent 8 }}") + .contains("volumeMounts: {{- toYaml (.Values.app.volumeMounts | default list) | nindent 12 }}"); + + for (var crdName : CRD_NAMES.keySet()) { + assertThat(chartPath.resolve("templates/%sreconciler-crd-role-binding.yaml".formatted( + crdName + ))).exists(); + } + + // 2. Prepare a temporary KubeConfig for the 'helm' CLI + // This ensures 'helm' uses the same Kubernetes cluster as the test environment (e.g., provided by DevServices). + var kubeConfigPath = createTempKubeConfig(); + + try { + // 3. Install the Helm chart using 'helm install' + var releaseName = "helm-install-test-" + System.nanoTime(); + + var holder = new Object() { + int exitCode; + }; + var installOutput = new StringBuilder(); + + ProcessBuilder.newBuilder( + "helm", + "install", releaseName, chartPath.toAbsolutePath().toString(), "--set", rootValuesAlias + ".image=garage-operator:test" + ).environment(Map.of( + ENV_VAR_KUBECONFIG, + kubeConfigPath.toAbsolutePath().toString() + )) + .exitCodeChecker(ec -> { + holder.exitCode = ec; + return true; + }) + .error().redirect() + .output() + .consumeLinesWith(65536, line -> installOutput.append(line).append(System.lineSeparator())) + .run(); + + int installExitCode = holder.exitCode; + assertThat(installExitCode) + .withFailMessage("Helm install failed with output:\n" + installOutput) + .isZero(); + + try { + // 4. Verify that the deployment is created in Kubernetes + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + var deployment = kubernetesClient.apps().deployments().withName(chartName).get(); + + assertThat(deployment).isNotNull(); + + // Helm sets labels based on the release name + assertThat(deployment.getMetadata()) + .isNotNull() + .satisfies(metadata -> assertThat(metadata.getLabels()) + .containsAllEntriesOf(Map.of( + "app.kubernetes.io/name", releaseName, + "app.kubernetes.io/managed-by", "Helm" + )) + ); + + assertThat(deployment.getSpec()) + .isNotNull() + .satisfies(spec -> { + var podSpec = spec.getTemplate().getSpec(); + + assertThat(podSpec.getImagePullSecrets()).isEmpty(); + assertThat(podSpec.getVolumes()).isEmpty(); + + // The baseline `kubernetes.yml` names the container, so Dekorate must + // not add a second one. + assertThat(podSpec.getContainers()) + .singleElement() + .satisfies(container -> { + assertThat(container.getName()).isEqualTo(kubernetesName); + assertThat(container.getVolumeMounts()).isEmpty(); + }); + }); + + var selector = deployment.getSpec().getSelector(); + + var pods = kubernetesClient.pods() + .withLabelSelector(selector) + .list() + .getItems(); + + assertThat(pods).isNotEmpty(); + }); + } finally { + // 5. Cleanup the created resources using 'helm uninstall' + ProcessBuilder.newBuilder( + "helm", + "uninstall", releaseName + ).environment(Map.of( + ENV_VAR_KUBECONFIG, + kubeConfigPath.toAbsolutePath().toString() + )) + .error().consumeLinesWith( + 8192, + log::error + ) + .run(); + } + } finally { + Files.deleteIfExists(kubeConfigPath); + } + } + + @Test + @DisplayName("When the chart is rendered with volumes, the deployment should mount them") + void helmTemplate_rendersVolumes() throws IOException { + // given + var chartPath = chartPath(); + + assertThat(chartPath) + .withFailMessage("Helm chart not found at %s. Ensure that the chart is generated before running this test.", chartPath) + .exists(); + + var valuesPath = createTempValuesWithVolumes(); + + try { + // `helm template` needs no cluster, and it validates the values against values.schema.json. + var holder = new Object() { + int exitCode; + }; + var renderedOutput = new StringBuilder(); + + // when + ProcessBuilder.newBuilder( + "helm", + "template", "volumes-render-test", chartPath.toAbsolutePath().toString(), + "--values", valuesPath.toAbsolutePath().toString() + ) + .exitCodeChecker(ec -> { + holder.exitCode = ec; + return true; + }) + .error().consumeLinesWith(8192, log::error) + .output() + .consumeLinesWith(65536, line -> renderedOutput.append(line).append(System.lineSeparator())) + .run(); + + // then + assertThat(holder.exitCode) + .withFailMessage("Helm template failed, see the logged error output") + .isZero(); + + var deployments = kubernetesClient.load(new ByteArrayInputStream( + renderedOutput.toString().getBytes(StandardCharsets.UTF_8) + )) + .items() + .stream() + .filter(Deployment.class::isInstance) + .map(Deployment.class::cast) + .toList(); + + assertThat(deployments) + .withFailMessage("The rendered chart must contain exactly one Deployment:%n%s", renderedOutput) + .hasSize(1); + + var deployment = deployments.getFirst(); + + // The baseline `kubernetes.yml` must name the Deployment `quarkus.kubernetes.name`. + // Dekorate keeps a different name as a second Deployment. + assertThat(deployment.getMetadata().getName()).isEqualTo(kubernetesName); + + // Without leader election, a rolling update would run two operators side by side. + assertThat(deployment.getSpec().getStrategy().getType()).isEqualTo("Recreate"); + + var podSpec = deployment.getSpec().getTemplate().getSpec(); + + // The `restricted` Pod Security Standard. + assertThat(podSpec.getSecurityContext().getRunAsNonRoot()).isTrue(); + assertThat(podSpec.getSecurityContext().getSeccompProfile().getType()).isEqualTo("RuntimeDefault"); + assertThat(podSpec.getContainers()) + .singleElement() + .satisfies(container -> { + assertThat(container.getSecurityContext().getAllowPrivilegeEscalation()).isFalse(); + assertThat(container.getSecurityContext().getCapabilities().getDrop()).containsExactly("ALL"); + }); + + assertThat(podSpec.getVolumes()) + .extracting(Volume::getName) + .containsExactly("db-credentials", "aws-secrets"); + + // A Secrets Store CSI volume is the case that `adminSecretFileRef` was added for. + assertThat(podSpec.getVolumes()) + .filteredOn(volume -> "aws-secrets".equals(volume.getName())) + .singleElement() + .satisfies(volume -> assertThat(volume.getCsi()) + .isNotNull() + .satisfies(csi -> { + assertThat(csi.getDriver()).isEqualTo("secrets-store.csi.k8s.io"); + assertThat(csi.getVolumeAttributes()) + .containsEntry("secretProviderClass", "db-credentials"); + }) + ); + + assertThat(podSpec.getImagePullSecrets()) + .extracting(LocalObjectReference::getName) + .containsExactly("my-registry-secret"); + + assertThat(podSpec.getContainers()) + .singleElement() + .satisfies(container -> { + assertThat(container.getName()).isEqualTo(kubernetesName); + assertThat(container.getVolumeMounts()) + .extracting(VolumeMount::getMountPath) + .containsExactly("/mnt/secrets", "/mnt/aws"); + }); + } finally { + Files.deleteIfExists(valuesPath); + } + } + + /// The chart is generated by the quarkus-helm extension in the build directory. + /// For Gradle, it's build/helm/kubernetes/garage-operator + private Path chartPath() { + return Paths.get("build", "helm", "kubernetes", chartName); + } + + private static Path createTempValuesWithVolumes() throws IOException { + var values = + """ + app: + image: garage-operator:test + imagePullSecrets: + - name: my-registry-secret + volumes: + - name: db-credentials + secret: + secretName: db-credentials-secret + - name: aws-secrets + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: db-credentials + volumeMounts: + - name: db-credentials + mountPath: /mnt/secrets + readOnly: true + - name: aws-secrets + mountPath: /mnt/aws + readOnly: true + """; + + var path = Files.createTempFile("values-volumes-helm-test-", ".yaml"); + + Files.writeString(path, values); + + return path; + } + + private Path createTempKubeConfig() throws IOException { + var clientConfig = kubernetesClient.getConfiguration(); + + var kubeConfig = new ConfigBuilder() + .addNewCluster() + .withName("dev-cluster") + .withNewCluster() + .withServer(clientConfig.getMasterUrl()) + .withCertificateAuthorityData(clientConfig.getCaCertData()) + .endCluster() + .endCluster() + .addNewUser() + .withName("dev-user") + .withNewUser() + .withClientCertificateData(clientConfig.getClientCertData()) + .withClientKeyData(clientConfig.getClientKeyData()) + .endUser() + .endUser() + .addNewContext() + .withName("dev-context") + .withNewContext() + .withCluster("dev-cluster") + .withUser("dev-user") + .withNamespace(clientConfig.getNamespace()) + .endContext() + .endContext() + .withCurrentContext("dev-context") + .build(); + + var path = Files.createTempFile("kubeconfig-helm-test-", ".yaml"); + + Files.writeString(path, Serialization.asYaml(kubeConfig)); + + return path; + } +} diff --git a/readme.md b/readme.md index b2d9328..6865868 100644 --- a/readme.md +++ b/readme.md @@ -1 +1,101 @@ -# AboutBits S3 Operator +# AboutBits Garage Operator + +AboutBits Garage Operator is a Kubernetes operator that manages S3 object storage declaratively: the cluster layout of a [Garage](https://garagehq.deuxfleurs.fr/) installation, buckets, access keys and the permissions that tie them together, all as Custom Resources. + +It is the companion of the [AboutBits Garage Helm chart](https://github.com/aboutbits/helm-garage): the chart runs Garage, the operator sets it up and manages what lives on it. + +## Compatibility + +| Component | Supported Versions | +|----------------|-------------------------------------------| +| **Garage** | 2.x (Admin API v2), tested against v2.3.0 | +| **Kubernetes** | 1.29+ | + +> **Note:** Kubernetes 1.29+ is required due to the use of CRD CEL validations (GA in 1.29, Beta in 1.25). + +## Contribute + +These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. + +### Prerequisites + +To build the project, the following prerequisites must be met: + +- Java JDK 25 (provisioned automatically by the Gradle toolchain) +- [Docker](https://www.docker.com/), for the Quarkus Dev Services +- The [`helm`](https://helm.sh/) CLI, for the Helm chart test + +### Setup + +To get started, you first need to configure the GitHub Gradle Packages registry to be able to pull the +[AboutBits Java Checkstyle Config](https://github.com/aboutbits/java-checkstyle-config) from the GitHub Packages registry. + +Follow +The guide basically tells you to click on `Generate new token (classic)` on , add the permission `read:packages` and copy the token which we need below. + +If it does not exist yet, create a file `~/.gradle/gradle.properties` in your home directory and add the following lines. + +```properties +gpr.user= +# The token generated above +gpr.key= +``` + +Alternatively, set the environment variables `GITHUB_USER_NAME` and `GITHUB_ACCESS_TOKEN`. + +Then call: + +```bash +make init + +# or + +./gradlew :operator:quarkusBuild +``` + +Enable the pre-commit hook with: + +```bash +git config core.hooksPath .githooks +``` + +### Development + +You can run the operator in dev mode, against a throwaway k3s cluster, using: + +```bash +make run + +# or + +./gradlew :operator:quarkusDev +``` + +To execute the tests, run: + +```bash +make test + +# or + +./gradlew :operator:test +``` + +The Helm chart installation test currently fails against **Helm 4**, whose server-side apply conflicts with the CRDs the fabric8 client already applied in test mode; CI runs Helm 3. The same is true of the sibling +[PostgreSQL Operator](https://github.com/aboutbits/postgresql-operator). + +## Information + +About Bits is a company based in South Tyrol, Italy. You can find more information about us on [our website](https://aboutbits.it). + +### Support + +For support, please contact [info@aboutbits.it](mailto:info@aboutbits.it). + +### Credits + +- [All Contributors](https://github.com/aboutbits/garage-operator/graphs/contributors) + +### License + +The MIT License (MIT). Please see the [license file](LICENSE) for more information. diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..da871c8 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,62 @@ +rootProject.name="garage-operator" + +include("operator") + +pluginManagement { + val quarkusPluginVersion = providers.gradleProperty("quarkusPluginVersion").get() + val quarkusPluginId = providers.gradleProperty("quarkusPluginId").get() + repositories { + mavenCentral() + gradlePluginPortal() + mavenLocal() + } + plugins { + id(quarkusPluginId) version quarkusPluginVersion + } +} + +// https://docs.gradle.org/current/userguide/best_practices_dependencies.html#set_up_repositories_in_settings +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + // This is a best practice that ensures all projects use the repositories defined here. + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + + repositories { + val githubUser = providers.gradleProperty("gpr.user") + .orElse(providers.environmentVariable("GITHUB_USER_NAME")) + val githubToken = providers.gradleProperty("gpr.key") + .orElse(providers.environmentVariable("GITHUB_ACCESS_TOKEN")) + + fun addGitHubRepo(name: String): MavenArtifactRepository { + return maven { + this.name = name + url = uri("https://maven.pkg.github.com/aboutbits/$name") + credentials { + username = githubUser.orNull + password = githubToken.orNull + } + } + } + + // https://docs.gradle.org/current/userguide/best_practices_dependencies.html#use_content_filtering + exclusiveContent { + forRepositories( + addGitHubRepo("java-checkstyle-config"), + mavenLocal() + ) + filter { + includeGroupAndSubgroups("it.aboutbits") + } + } + + mavenCentral() + mavenLocal() + } +} + +plugins { + // https://docs.gradle.org/current/userguide/toolchains.html#sec:provisioning + // https://plugins.gradle.org/plugin/org.gradle.toolchains.foojay-resolver-convention + // https://github.com/gradle/foojay-toolchains + id("org.gradle.toolchains.foojay-resolver-convention").version("1.0.0") +}