Repository: armadaproject/armada

Issue:
Integration external java-client into Armada repository
There is an Armada client library for the Java language at https://github.com/armadaproject/java-client/ . In order to more closely track any GRPC API changes in Armada, and to more easily add test logic for that library code, we should move that code directly into the Armada repo (https://github.com/armadaproject/armada/), just as we currently have the Python, C#/.Net, and Scala clients.

The code should be placed underneath the existing `client/` directory, e.g. `client/java/`. Probably we should preserve the existing layout of the current external `java-client` repo underneath this directory, and if possible, we should see if we can somehow use the original commit history, to preserve the original authorship.

Also, the current `java-client` repo does not have unit tests. After relocating the code, we should add tests for this code. The Scala client has basic unit tests, plus a mock Armada server setup - since Scala has interoperability with Java, we should investigate enhancing/adapting the Scala client unit tests to also link with the java-client library (instead of the Scala client library), and attempt using the Scala client tests to exercise the java client code.


Hints from issue comments:

Solution patch:
diff --git a/.github/workflows/java-client-release-to-maven.yml b/.github/workflows/java-client-release-to-maven.yml
new file mode 100644
index 00000000..47f2d94a
--- /dev/null
+++ b/.github/workflows/java-client-release-to-maven.yml
@@ -0,0 +1,42 @@
+name: Release Java Client to Maven
+
+on:
+  workflow_dispatch:
+
+jobs:
+  build:
+    environment: maven-release
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - name: Set up Maven Central Repository
+      uses: actions/setup-java@c3ac5dd0ed8db40fedb61c32fbe677e6b355e94c
+      with:
+        java-version: '11'
+        distribution: 'temurin'
+        server-id: central
+        server-username: MAVEN_USERNAME
+        server-password: MAVEN_PASSWORD
+        gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }}
+        gpg-passphrase: MAVEN_GPG_PASSPHRASE
+
+    - name: Build
+      run: |
+        cp settings.xml /home/runner/.m2
+        cat /home/runner/.m2/settings.xml
+        mvn clean install
+      env:
+        MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }}
+
+    - name: Release artifacts
+      run: |
+        pwd
+        ls -la /home/runner/.m2
+        cat /home/runner/.m2/settings.xml
+        mvn --batch-mode deploy -DskipTests -Pci
+      env:
+        MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
+        MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
+        MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }}
diff --git a/.github/workflows/java-client.yml b/.github/workflows/java-client.yml
new file mode 100644
index 00000000..a68c7423
--- /dev/null
+++ b/.github/workflows/java-client.yml
@@ -0,0 +1,54 @@
+name: Java Client
+
+on:
+  push:
+    branches-ignore:
+      - master
+    paths:
+      - 'client/java/**'
+      - 'build/java-client/**'
+      - 'pkg/api/*.proto'
+      - '.github/workflows/java-client.yml'
+      - 'scripts/build-java-client.sh'
+      - 'makefile'
+      - './magefiles/java.go'
+
+  pull_request:
+    branches-ignore:
+      - gh-pages
+    paths:
+      - 'client/java/**'
+      - 'build/java-client/**'
+      - 'pkg/api/*.proto'
+      - '.github/workflows/java-client.yml'
+      - 'scripts/build-java-client.sh'
+      - 'makefile'
+      - './magefiles/java.go'
+
+jobs:
+  java-client-test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up JDK 11
+        uses: actions/setup-java@v4
+        with:
+          java-version: '11'
+          distribution: 'temurin'
+
+      - name: Install Maven
+        run: sudo apt-get update && sudo apt-get install -y maven
+
+      - name: Restore Maven packages cache
+        uses: actions/cache@v4
+        with:
+          path: ~/.m2/repository
+          key: ${{ runner.os }}-mvn-build-${{ hashFiles('client/java/pom.xml') }}
+          restore-keys:
+            ${{ runner.os }}-mvn-build-${{ hashFiles('client/java/pom.xml') }}
+            ${{ runner.os }}-mvn-build-
+
+      - name: Run unit tests
+        working-directory: client/java
+        run: mvn --batch-mode test
diff --git a/build/java-client/Dockerfile b/build/java-client/Dockerfile
new file mode 100644
index 00000000..b9a6f1c0
--- /dev/null
+++ b/build/java-client/Dockerfile
@@ -0,0 +1,28 @@
+FROM eclipse-temurin:11-jdk
+
+ARG MAVEN_VERSION=3.9.9
+
+LABEL org.opencontainers.image.authors="G-Research Open-Source Software"
+LABEL org.opencontainers.image.licenses="Apache-2.0"
+LABEL org.opencontainers.image.ref.name="Java 11 Client Builder"
+LABEL org.opencontainers.image.version=""
+
+RUN set -ex && \
+    apt update && \
+    apt install -y software-properties-common && \
+    apt-add-repository -y ppa:git-core/ppa && \
+    apt update && \
+    apt install -y curl git && \
+    # Download and install Maven
+    curl -s -O https://downloads.apache.org/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz && \
+    tar -C /opt -xzvf apache-maven-${MAVEN_VERSION}-bin.tar.gz && \
+    ln -s /opt/apache-maven-${MAVEN_VERSION}/bin/mvn /usr/local/bin/mvn && \
+    # Clean up
+    apt-get clean && \
+    rm -rf apache-maven-${MAVEN_VERSION}-bin.tar.gz /var/lib/apt/lists/*
+
+
+COPY scripts/build-java-client.sh /
+RUN chmod +x /build-java-client.sh
+
+ENTRYPOINT [ "/build-java-client.sh" ]
diff --git a/client/java/.gitignore b/client/java/.gitignore
new file mode 100644
index 00000000..162e662b
--- /dev/null
+++ b/client/java/.gitignore
@@ -0,0 +1,41 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### IntelliJ IDEA ###
+.idea/modules.xml
+.idea/jarRepositories.xml
+.idea/compiler.xml
+.idea/libraries/
+*.iws
+*.iml
+*.ipr
+.idea
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
+
+src/main/proto
\ No newline at end of file
diff --git a/client/java/README.md b/client/java/README.md
new file mode 100644
index 00000000..a66cd1fb
--- /dev/null
+++ b/client/java/README.md
@@ -0,0 +1,50 @@
+# Armada Java gRPC Client
+
+This is the Java client for interacting with the Armada gRPC API.
+
+## Requirements
+
+- **Java 11** (required by Jenkins K8s plugin)
+- **Maven 3.9.9**
+
+## Quick Start
+
+### Build
+From the root of the Armada repository, run:
+```bash
+mage BuildJava
+```
+
+### Run Example
+Example programs are located in the [examples](src/main/java/io/armadaproject/examples/) directory.
+
+Ensure an **Armada server** is running on `localhost:30002` before executing the example:
+```bash
+mvn exec:java -Dexec.mainClass="io.armadaproject.examples.SubmitJob"
+```
+
+## Development
+
+### Local Build
+```bash
+mvn clean package
+```
+
+### Run Tests
+```bash
+mvn clean test
+```
+
+## Troubleshooting
+
+### IntelliJ Not Recognizing Generated Sources
+If IntelliJ doesn't recognize generated sources, update the IDE settings:
+
+1. Go to **Help > Edit Custom Properties**
+2. Add the following line:
+   ```
+   idea.max.intellisense.filesize=20000
+   ```
+3. Restart IntelliJ
+
+
diff --git a/client/java/dev-support/checkstyle-suppressions.xml b/client/java/dev-support/checkstyle-suppressions.xml
new file mode 100644
index 00000000..cf65bd3e
--- /dev/null
+++ b/client/java/dev-support/checkstyle-suppressions.xml
@@ -0,0 +1,9 @@
+<!DOCTYPE suppressions PUBLIC
+    "-//Checkstyle//DTD Suppressions 1.2//EN"
+    "https://checkstyle.org/dtds/suppressions_1_2.dtd">
+
+<suppressions>
+  <!-- Suppress the JavadocPackage check for missing package-info.java -->
+  <suppress checks="MissingJavadocMethod" files=".*"/>
+  <suppress checks="MissingJavadocType" files=".*" />
+</suppressions>
\ No newline at end of file
diff --git a/client/java/dev-support/google_checks.xml b/client/java/dev-support/google_checks.xml
new file mode 100644
index 00000000..8475b41f
--- /dev/null
+++ b/client/java/dev-support/google_checks.xml
@@ -0,0 +1,418 @@
+<?xml version="1.0"?>
+<!DOCTYPE module PUBLIC
+          "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
+          "https://checkstyle.org/dtds/configuration_1_3.dtd">
+
+<!--
+    Checkstyle configuration that checks the Google coding conventions from Google Java Style
+    that can be found at https://google.github.io/styleguide/javaguide.html
+
+    Checkstyle is very configurable. Be sure to read the documentation at
+    http://checkstyle.org (or in your downloaded distribution).
+
+    To completely disable a check, just comment it out or delete it from the file.
+    To suppress certain violations please review suppression filters.
+
+    Authors: Max Vetrenko, Ruslan Diachenko, Roman Ivanov.
+ -->
+
+<module name="Checker">
+
+  <property name="charset" value="UTF-8"/>
+
+  <property name="severity" value="${org.checkstyle.google.severity}" default="warning"/>
+
+  <property name="fileExtensions" value="java, properties, xml"/>
+  <!-- Excludes all 'module-info.java' files              -->
+  <!-- See https://checkstyle.org/filefilters/index.html -->
+  <module name="BeforeExecutionExclusionFileFilter">
+    <property name="fileNamePattern" value="module\-info\.java$"/>
+  </module>
+
+  <module name="SuppressWarningsFilter"/>
+
+  <!-- https://checkstyle.org/filters/suppressionfilter.html -->
+  <module name="SuppressionFilter">
+    <property name="file" value="${org.checkstyle.google.suppressionfilter.config}"
+           default="checkstyle-suppressions.xml" />
+    <property name="optional" value="true"/>
+  </module>
+
+  <!-- https://checkstyle.org/filters/suppresswithnearbytextfilter.html -->
+  <module name="SuppressWithNearbyTextFilter">
+    <property name="nearbyTextPattern"
+              value="CHECKSTYLE.SUPPRESS\: (\w+) for ([+-]\d+) lines"/>
+    <property name="checkPattern" value="$1"/>
+    <property name="lineRange" value="$2"/>
+  </module>
+
+  <!-- Checks for whitespace                               -->
+  <!-- See http://checkstyle.org/checks/whitespace/index.html -->
+  <module name="FileTabCharacter">
+    <property name="eachLine" value="true"/>
+  </module>
+
+  <module name="LineLength">
+    <property name="fileExtensions" value="java"/>
+    <property name="max" value="100"/>
+    <property name="ignorePattern"
+             value="^package.*|^import.*|href\s*=\s*&quot;[^&quot;]*&quot;|http://|https://|ftp://"/>
+  </module>
+
+  <module name="TreeWalker">
+    <module name="OuterTypeFilename"/>
+    <module name="IllegalTokenText">
+      <property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
+      <property name="format"
+               value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
+      <property name="message"
+               value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
+    </module>
+    <module name="AvoidEscapedUnicodeCharacters">
+      <property name="allowEscapesForControlCharacters" value="true"/>
+      <property name="allowByTailComment" value="true"/>
+      <property name="allowNonPrintableEscapes" value="true"/>
+    </module>
+    <module name="AvoidStarImport"/>
+    <module name="OneTopLevelClass"/>
+    <module name="NoLineWrap">
+      <property name="tokens" value="PACKAGE_DEF, IMPORT, STATIC_IMPORT"/>
+    </module>
+    <module name="NeedBraces">
+      <property name="tokens"
+               value="LITERAL_DO, LITERAL_ELSE, LITERAL_FOR, LITERAL_IF, LITERAL_WHILE"/>
+    </module>
+    <module name="LeftCurly">
+      <property name="tokens"
+               value="ANNOTATION_DEF, CLASS_DEF, CTOR_DEF, ENUM_CONSTANT_DEF, ENUM_DEF,
+                    INTERFACE_DEF, LAMBDA, LITERAL_CASE, LITERAL_CATCH, LITERAL_DEFAULT,
+                    LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY, LITERAL_FOR, LITERAL_IF,
+                    LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE, METHOD_DEF,
+                    OBJBLOCK, STATIC_INIT, RECORD_DEF, COMPACT_CTOR_DEF"/>
+    </module>
+    <module name="RightCurly">
+      <property name="id" value="RightCurlySame"/>
+      <property name="tokens"
+               value="LITERAL_TRY, LITERAL_CATCH, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE,
+                    LITERAL_DO"/>
+    </module>
+    <module name="RightCurly">
+      <property name="id" value="RightCurlyAlone"/>
+      <property name="option" value="alone"/>
+      <property name="tokens"
+               value="CLASS_DEF, METHOD_DEF, CTOR_DEF, LITERAL_FOR, LITERAL_WHILE, STATIC_INIT,
+                    INSTANCE_INIT, ANNOTATION_DEF, ENUM_DEF, INTERFACE_DEF, RECORD_DEF,
+                    COMPACT_CTOR_DEF, LITERAL_SWITCH, LITERAL_CASE"/>
+    </module>
+    <module name="SuppressionXpathSingleFilter">
+      <!-- suppresion is required till https://github.com/checkstyle/checkstyle/issues/7541 -->
+      <property name="id" value="RightCurlyAlone"/>
+      <property name="query" value="//RCURLY[parent::SLIST[count(./*)=1]
+                                     or preceding-sibling::*[last()][self::LCURLY]]"/>
+    </module>
+    <module name="WhitespaceAfter">
+      <property name="tokens"
+               value="COMMA, SEMI, TYPECAST, LITERAL_IF, LITERAL_ELSE, LITERAL_RETURN,
+                    LITERAL_WHILE, LITERAL_DO, LITERAL_FOR, LITERAL_FINALLY, DO_WHILE, ELLIPSIS,
+                    LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_CATCH, LAMBDA,
+                    LITERAL_YIELD, LITERAL_CASE, LITERAL_WHEN"/>
+    </module>
+    <module name="WhitespaceAround">
+      <property name="allowEmptyConstructors" value="true"/>
+      <property name="allowEmptyLambdas" value="true"/>
+      <property name="allowEmptyMethods" value="true"/>
+      <property name="allowEmptyTypes" value="true"/>
+      <property name="allowEmptyLoops" value="true"/>
+      <property name="ignoreEnhancedForColon" value="false"/>
+      <property name="tokens"
+               value="ASSIGN, BAND, BAND_ASSIGN, BOR, BOR_ASSIGN, BSR, BSR_ASSIGN, BXOR,
+                    BXOR_ASSIGN, COLON, DIV, DIV_ASSIGN, DO_WHILE, EQUAL, GE, GT, LAMBDA, LAND,
+                    LCURLY, LE, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY,
+                    LITERAL_FOR, LITERAL_IF, LITERAL_RETURN, LITERAL_SWITCH, LITERAL_SYNCHRONIZED,
+                    LITERAL_TRY, LITERAL_WHILE, LOR, LT, MINUS, MINUS_ASSIGN, MOD, MOD_ASSIGN,
+                    NOT_EQUAL, PLUS, PLUS_ASSIGN, QUESTION, RCURLY, SL, SLIST, SL_ASSIGN, SR,
+                    SR_ASSIGN, STAR, STAR_ASSIGN, LITERAL_ASSERT,
+                    TYPE_EXTENSION_AND, LITERAL_WHEN"/>
+      <message key="ws.notFollowed"
+              value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks
+               may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
+      <message key="ws.notPreceded"
+              value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
+    </module>
+    <module name="SuppressionXpathSingleFilter">
+      <property name="checks" value="WhitespaceAround"/>
+      <property name="query" value="//*[self::LITERAL_IF or self::LITERAL_ELSE or self::STATIC_INIT
+                                 or self::LITERAL_TRY or self::LITERAL_CATCH]/SLIST[count(./*)=1]
+                                 | //*[self::STATIC_INIT or self::LITERAL_TRY or self::LITERAL_IF]
+                                 //*[self::RCURLY][parent::SLIST[count(./*)=1]]"/>
+    </module>
+    <module name="RegexpSinglelineJava">
+      <property name="format" value="\{[ ]+\}"/>
+      <property name="message" value="Empty blocks should have no spaces. Empty blocks
+                                   may only be represented as '{}' when not part of a
+                                   multi-block statement (4.1.3)"/>
+    </module>
+    <module name="OneStatementPerLine"/>
+    <module name="MultipleVariableDeclarations"/>
+    <module name="ArrayTypeStyle"/>
+    <module name="MissingSwitchDefault"/>
+    <module name="FallThrough"/>
+    <module name="UpperEll"/>
+    <module name="ModifierOrder"/>
+    <module name="EmptyLineSeparator">
+      <property name="tokens"
+               value="PACKAGE_DEF, IMPORT, STATIC_IMPORT, CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
+                    STATIC_INIT, INSTANCE_INIT, METHOD_DEF, CTOR_DEF, VARIABLE_DEF, RECORD_DEF,
+                    COMPACT_CTOR_DEF"/>
+      <property name="allowNoEmptyLineBetweenFields" value="true"/>
+    </module>
+    <module name="SeparatorWrap">
+      <property name="id" value="SeparatorWrapDot"/>
+      <property name="tokens" value="DOT"/>
+      <property name="option" value="nl"/>
+    </module>
+    <module name="SeparatorWrap">
+      <property name="id" value="SeparatorWrapComma"/>
+      <property name="tokens" value="COMMA"/>
+      <property name="option" value="EOL"/>
+    </module>
+    <module name="SeparatorWrap">
+      <!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/259 -->
+      <property name="id" value="SeparatorWrapEllipsis"/>
+      <property name="tokens" value="ELLIPSIS"/>
+      <property name="option" value="EOL"/>
+    </module>
+    <module name="SeparatorWrap">
+      <!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/258 -->
+      <property name="id" value="SeparatorWrapArrayDeclarator"/>
+      <property name="tokens" value="ARRAY_DECLARATOR"/>
+      <property name="option" value="EOL"/>
+    </module>
+    <module name="SeparatorWrap">
+      <property name="id" value="SeparatorWrapMethodRef"/>
+      <property name="tokens" value="METHOD_REF"/>
+      <property name="option" value="nl"/>
+    </module>
+    <module name="PackageName">
+      <property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
+      <message key="name.invalidPattern"
+             value="Package name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="TypeName">
+      <property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
+                    ANNOTATION_DEF, RECORD_DEF"/>
+      <message key="name.invalidPattern"
+             value="Type name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="MemberName">
+      <property name="format" value="^[a-z][a-z0-9][a-zA-Z0-9]*$"/>
+      <message key="name.invalidPattern"
+             value="Member name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="ParameterName">
+      <property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
+      <message key="name.invalidPattern"
+             value="Parameter name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="LambdaParameterName">
+      <property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
+      <message key="name.invalidPattern"
+             value="Lambda parameter name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="CatchParameterName">
+      <property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
+      <message key="name.invalidPattern"
+             value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="LocalVariableName">
+      <property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
+      <message key="name.invalidPattern"
+             value="Local variable name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="PatternVariableName">
+      <property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
+      <message key="name.invalidPattern"
+             value="Pattern variable name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="ClassTypeParameterName">
+      <property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
+      <message key="name.invalidPattern"
+             value="Class type name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="RecordComponentName">
+      <property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
+      <message key="name.invalidPattern"
+               value="Record component name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="RecordTypeParameterName">
+      <property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
+      <message key="name.invalidPattern"
+               value="Record type name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="MethodTypeParameterName">
+      <property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
+      <message key="name.invalidPattern"
+             value="Method type name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="InterfaceTypeParameterName">
+      <property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
+      <message key="name.invalidPattern"
+             value="Interface type name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="NoFinalizer"/>
+    <module name="GenericWhitespace">
+      <message key="ws.followed"
+             value="GenericWhitespace ''{0}'' is followed by whitespace."/>
+      <message key="ws.preceded"
+             value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
+      <message key="ws.illegalFollow"
+             value="GenericWhitespace ''{0}'' should followed by whitespace."/>
+      <message key="ws.notPreceded"
+             value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
+    </module>
+    <module name="Indentation">
+      <property name="basicOffset" value="2"/>
+      <property name="braceAdjustment" value="2"/>
+      <property name="caseIndent" value="2"/>
+      <property name="throwsIndent" value="4"/>
+      <property name="lineWrappingIndentation" value="4"/>
+      <property name="arrayInitIndent" value="2"/>
+    </module>
+    <module name="AbbreviationAsWordInName">
+      <property name="ignoreFinal" value="false"/>
+      <property name="allowedAbbreviationLength" value="0"/>
+      <property name="tokens"
+               value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, ANNOTATION_DEF, ANNOTATION_FIELD_DEF,
+                    PARAMETER_DEF, VARIABLE_DEF, METHOD_DEF, PATTERN_VARIABLE_DEF, RECORD_DEF,
+                    RECORD_COMPONENT_DEF"/>
+    </module>
+    <module name="NoWhitespaceBeforeCaseDefaultColon"/>
+    <module name="OverloadMethodsDeclarationOrder"/>
+    <module name="ConstructorsDeclarationGrouping"/>
+    <module name="VariableDeclarationUsageDistance"/>
+    <module name="CustomImportOrder">
+      <property name="sortImportsInGroupAlphabetically" value="true"/>
+      <property name="separateLineBetweenGroups" value="true"/>
+      <property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
+      <property name="tokens" value="IMPORT, STATIC_IMPORT, PACKAGE_DEF"/>
+    </module>
+    <module name="MethodParamPad">
+      <property name="tokens"
+               value="CTOR_DEF, LITERAL_NEW, METHOD_CALL, METHOD_DEF, CTOR_CALL,
+                    SUPER_CTOR_CALL, ENUM_CONSTANT_DEF, RECORD_DEF, RECORD_PATTERN_DEF"/>
+    </module>
+    <module name="NoWhitespaceBefore">
+      <property name="tokens"
+               value="COMMA, SEMI, POST_INC, POST_DEC, DOT,
+                    LABELED_STAT, METHOD_REF"/>
+      <property name="allowLineBreaks" value="true"/>
+    </module>
+    <module name="ParenPad">
+      <property name="tokens"
+               value="ANNOTATION, ANNOTATION_FIELD_DEF, CTOR_CALL, CTOR_DEF, DOT, ENUM_CONSTANT_DEF,
+                    EXPR, LITERAL_CATCH, LITERAL_DO, LITERAL_FOR, LITERAL_IF, LITERAL_NEW,
+                    LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_WHILE, METHOD_CALL,
+                    METHOD_DEF, QUESTION, RESOURCE_SPECIFICATION, SUPER_CTOR_CALL, LAMBDA,
+                    RECORD_DEF, RECORD_PATTERN_DEF"/>
+    </module>
+    <module name="OperatorWrap">
+      <property name="option" value="NL"/>
+      <property name="tokens"
+               value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR,
+                    LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF,
+                    TYPE_EXTENSION_AND "/>
+    </module>
+    <module name="AnnotationLocation">
+      <property name="id" value="AnnotationLocationMostCases"/>
+      <property name="tokens"
+               value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF,
+                      RECORD_DEF, COMPACT_CTOR_DEF"/>
+    </module>
+    <module name="AnnotationLocation">
+      <property name="id" value="AnnotationLocationVariables"/>
+      <property name="tokens" value="VARIABLE_DEF"/>
+      <property name="allowSamelineMultipleAnnotations" value="true"/>
+    </module>
+    <module name="NonEmptyAtclauseDescription"/>
+    <module name="InvalidJavadocPosition"/>
+    <module name="JavadocTagContinuationIndentation"/>
+    <module name="SummaryJavadoc">
+      <property name="forbiddenSummaryFragments"
+               value="^@return the *|^This method returns |^A [{]@code [a-zA-Z0-9]+[}]( is a )"/>
+    </module>
+    <module name="JavadocParagraph"/>
+    <module name="RequireEmptyLineBeforeBlockTagGroup"/>
+    <module name="AtclauseOrder">
+      <property name="tagOrder" value="@param, @return, @throws, @deprecated"/>
+      <property name="target"
+               value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF, VARIABLE_DEF"/>
+    </module>
+    <module name="JavadocMethod">
+      <property name="accessModifiers" value="public"/>
+      <property name="allowMissingParamTags" value="true"/>
+      <property name="allowMissingReturnTag" value="true"/>
+      <property name="allowedAnnotations" value="Override, Test"/>
+      <property name="tokens" value="METHOD_DEF, CTOR_DEF, ANNOTATION_FIELD_DEF, COMPACT_CTOR_DEF"/>
+    </module>
+    <module name="MissingJavadocMethod">
+      <property name="scope" value="protected"/>
+      <property name="allowMissingPropertyJavadoc" value="true"/>
+      <property name="allowedAnnotations" value="Override, Test"/>
+      <property name="tokens" value="METHOD_DEF, CTOR_DEF, ANNOTATION_FIELD_DEF,
+                                   COMPACT_CTOR_DEF"/>
+    </module>
+    <module name="SuppressionXpathSingleFilter">
+      <property name="checks" value="MissingJavadocMethod"/>
+      <property name="query" value="//*[self::METHOD_DEF or self::CTOR_DEF
+                                 or self::ANNOTATION_FIELD_DEF or self::COMPACT_CTOR_DEF]
+                                 [ancestor::*[self::INTERFACE_DEF or self::CLASS_DEF
+                                 or self::RECORD_DEF or self::ENUM_DEF]
+                                 [not(./MODIFIERS/LITERAL_PUBLIC)]]"/>
+    </module>
+    <module name="MissingJavadocType">
+      <property name="scope" value="protected"/>
+      <property name="tokens"
+                value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
+                      RECORD_DEF, ANNOTATION_DEF"/>
+      <property name="excludeScope" value="nothing"/>
+    </module>
+    <module name="MethodName">
+      <property name="format" value="^[a-z][a-z0-9][a-zA-Z0-9]*$"/>
+      <message key="name.invalidPattern"
+             value="Method name ''{0}'' must match pattern ''{1}''."/>
+    </module>
+    <module name="SuppressionXpathSingleFilter">
+      <property name="checks" value="MethodName"/>
+      <property name="query" value="//METHOD_DEF[
+                                     ./MODIFIERS/ANNOTATION//IDENT[contains(@text, 'Test')]
+                                   ]/IDENT"/>
+      <property name="message" value="'[a-z][a-z0-9][a-zA-Z0-9]*(?:_[a-z][a-z0-9][a-zA-Z0-9]*)*'"/>
+    </module>
+    <module name="SingleLineJavadoc"/>
+    <module name="EmptyCatchBlock">
+      <property name="exceptionVariableName" value="expected"/>
+    </module>
+    <module name="CommentsIndentation">
+      <property name="tokens" value="SINGLE_LINE_COMMENT, BLOCK_COMMENT_BEGIN"/>
+    </module>
+    <!-- https://checkstyle.org/filters/suppressionxpathfilter.html -->
+    <module name="SuppressionXpathFilter">
+      <property name="file" value="${org.checkstyle.google.suppressionxpathfilter.config}"
+             default="checkstyle-xpath-suppressions.xml" />
+      <property name="optional" value="true"/>
+    </module>
+    <module name="SuppressWarningsHolder" />
+    <module name="SuppressionCommentFilter">
+      <property name="offCommentFormat" value="CHECKSTYLE.OFF\: ([\w\|]+)" />
+      <property name="onCommentFormat" value="CHECKSTYLE.ON\: ([\w\|]+)" />
+      <property name="checkFormat" value="$1" />
+    </module>
+    <module name="SuppressWithNearbyCommentFilter">
+      <property name="commentFormat" value="CHECKSTYLE.SUPPRESS\: ([\w\|]+)"/>
+      <!-- $1 refers to the first match group in the regex defined in commentFormat -->
+      <property name="checkFormat" value="$1"/>
+      <!-- The check is suppressed in the next line of code after the comment -->
+      <property name="influenceFormat" value="1"/>
+    </module>
+  </module>
+</module>
diff --git a/client/java/pom.xml b/client/java/pom.xml
new file mode 100644
index 00000000..12f90382
--- /dev/null
+++ b/client/java/pom.xml
@@ -0,0 +1,414 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>io.armadaproject</groupId>
+  <artifactId>java-client</artifactId>
+  <version>0.0.2</version>
+  <packaging>jar</packaging>
+  <description>Java client library for the Armada project</description>
+  <name>java-client</name>
+  <url>https://github.com/armadaproject/java-client</url>
+
+  <properties>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    <maven-compiler-plugin.release>11</maven-compiler-plugin.release>
+    <io.grpc.version>1.67.1</io.grpc.version>
+    <protobuf-java.version>3.25.3</protobuf-java.version>
+    <os-maven-plugin.version>1.7.1</os-maven-plugin.version>
+    <maven-checkstyle-plugin.version>3.5.0</maven-checkstyle-plugin.version>
+    <checkstyle.version>10.18.2</checkstyle.version>
+    <maven-compiler-plugin.version>3.11.0</maven-compiler-plugin.version>
+    <protobuf-maven-plugin.version>0.6.1</protobuf-maven-plugin.version>
+    <annotation-api.version>1.3.2</annotation-api.version>
+    <exec-maven-plugin.version>3.0.0</exec-maven-plugin.version>
+    <org.junit.jupiter.version>5.11.1</org.junit.jupiter.version>
+    <maven-resources-plugin.version>3.3.1</maven-resources-plugin.version>
+    <mockito.version>5.5.0</mockito.version>
+  </properties>
+
+  <licenses>
+    <license>
+      <name>Apache 2.0 License</name>
+      <url>http://www.apache.org/licenses/LICENSE-2.0.html</url>
+      <distribution>repo</distribution>
+    </license>
+  </licenses>
+
+  <developers>
+    <developer>
+      <name>Mladjan Gadzic</name>
+      <email>mladjan@gr-oss.io</email>
+      <organization>G-Research</organization>
+      <organizationUrl>https://www.gresearch.com/</organizationUrl>
+    </developer>
+  </developers>
+
+  <scm>
+    <connection>scm:git:git://github.com/armadaproject/java-client.git</connection>
+    <developerConnection>scm:git:ssh://github.com:armadaproject/java-client.git
+    </developerConnection>
+    <url>http://github.com/armadaproject/java-client/tree/main</url>
+    <tag/>
+  </scm>
+
+  <distributionManagement>
+    <repository>
+      <id>central</id>
+      <url>https://oss.sonatype.org/service/local/staging/deploy/maven2/</url>
+    </repository>
+  </distributionManagement>
+
+  <profiles>
+    <profile>
+      <id>local</id>
+      <properties>
+        <env>local</env>
+      </properties>
+      <activation>
+        <activeByDefault>true</activeByDefault>
+      </activation>
+      <build>
+        <plugins>
+          <!-- get proto files -->
+          <plugin>
+            <groupId>org.codehaus.mojo</groupId>
+            <artifactId>exec-maven-plugin</artifactId>
+            <version>${exec-maven-plugin.version}</version>
+            <executions>
+              <execution>
+                <id>clone-armada-repo</id>
+                <phase>generate-sources</phase>
+                <goals>
+                  <goal>exec</goal>
+                </goals>
+                <configuration>
+                  <executable>git</executable>
+                  <arguments>
+                    <argument>clone</argument>
+                    <argument>https://github.com/armadaproject/armada.git</argument>
+                    <argument>${project.build.directory}/armada</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+              <execution>
+                <id>clone-api-repo</id>
+                <phase>generate-sources</phase>
+                <goals>
+                  <goal>exec</goal>
+                </goals>
+                <configuration>
+                  <executable>git</executable>
+                  <arguments>
+                    <argument>clone</argument>
+                    <argument>https://github.com/kubernetes/api.git</argument>
+                    <argument>${project.build.directory}/k8s.io/api</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+              <execution>
+                <id>clone-apimachinery-repo</id>
+                <phase>generate-sources</phase>
+                <goals>
+                  <goal>exec</goal>
+                </goals>
+                <configuration>
+                  <executable>git</executable>
+                  <arguments>
+                    <argument>clone</argument>
+                    <argument>https://github.com/kubernetes/apimachinery.git</argument>
+                    <argument>${project.build.directory}/k8s.io/apimachinery</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+
+          <!-- copy proto files -->
+          <plugin>
+            <artifactId>maven-resources-plugin</artifactId>
+            <version>${maven-resources-plugin.version}</version>
+            <executions>
+              <execution>
+                <id>copy-protos</id>
+                <phase>generate-sources</phase>
+                <goals>
+                  <goal>copy-resources</goal>
+                </goals>
+                <configuration>
+                  <outputDirectory>${project.basedir}/src/main/proto</outputDirectory>
+                  <resources>
+                    <resource>
+                      <directory>${project.build.directory}/armada</directory>
+                      <includes>
+                        <include>pkg/api/*.proto</include>
+                        <include>pkg/api/binoculars/*.proto</include>
+                      </includes>
+                    </resource>
+                    <resource>
+                      <directory>${project.build.directory}</directory>
+                      <includes>
+                        <include>k8s.io/api/core/v1/generated.proto</include>
+                        <include>k8s.io/api/networking/v1/generated.proto</include>
+                        <include>k8s.io/apimachinery/pkg/api/resource/generated.proto</include>
+                        <include>k8s.io/apimachinery/pkg/apis/meta/v1/generated.proto</include>
+                        <include>k8s.io/apimachinery/pkg/runtime/generated.proto</include>
+                        <include>k8s.io/apimachinery/pkg/runtime/schema/generated.proto</include>
+                        <include>k8s.io/apimachinery/pkg/util/intstr/generated.proto</include>
+                      </includes>
+                    </resource>
+                  </resources>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+
+          <!-- detect os -->
+          <plugin>
+            <groupId>kr.motd.maven</groupId>
+            <artifactId>os-maven-plugin</artifactId>
+            <version>${os-maven-plugin.version}</version>
+            <executions>
+              <execution>
+                <goals>
+                  <goal>detect</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+
+          <!-- checkstyle -->
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-checkstyle-plugin</artifactId>
+            <version>${maven-checkstyle-plugin.version}</version>
+            <dependencies>
+              <dependency>
+                <groupId>com.puppycrawl.tools</groupId>
+                <artifactId>checkstyle</artifactId>
+                <version>${checkstyle.version}</version>
+              </dependency>
+            </dependencies>
+            <configuration>
+              <configLocation>dev-support/google_checks.xml</configLocation>
+              <suppressionsLocation>dev-support/checkstyle-suppressions.xml</suppressionsLocation>
+              <consoleOutput>true</consoleOutput>
+              <failsOnError>true</failsOnError>
+              <failOnViolation>true</failOnViolation>
+              <violationSeverity>warning</violationSeverity>
+              <linkXRef>false</linkXRef>
+            </configuration>
+            <executions>
+              <execution>
+                <id>validate</id>
+                <phase>validate</phase>
+                <goals>
+                  <goal>check</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+
+          <!-- generate java sources from proto -->
+          <plugin>
+            <groupId>org.xolstice.maven.plugins</groupId>
+            <artifactId>protobuf-maven-plugin</artifactId>
+            <version>${protobuf-maven-plugin.version}</version>
+            <configuration>
+              <protocArtifact>
+                com.google.protobuf:protoc:${protobuf-java.version}:exe:${os.detected.classifier}
+              </protocArtifact>
+              <pluginId>grpc-java</pluginId>
+              <pluginArtifact>
+                io.grpc:protoc-gen-grpc-java:${io.grpc.version}:exe:${os.detected.classifier}
+              </pluginArtifact>
+            </configuration>
+            <executions>
+              <execution>
+                <goals>
+                  <goal>compile</goal>
+                  <goal>compile-custom</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+
+          <!-- compile java -->
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-compiler-plugin</artifactId>
+            <version>${maven-compiler-plugin.version}</version>
+            <configuration>
+              <release>${maven-compiler-plugin.release}</release>
+            </configuration>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+
+    <profile>
+      <id>ci</id>
+      <properties>
+        <env>ci</env>
+      </properties>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-source-plugin</artifactId>
+            <version>3.3.0</version>
+            <executions>
+              <execution>
+                <id>attach-sources</id>
+                <goals>
+                  <goal>jar-no-fork</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-javadoc-plugin</artifactId>
+            <version>3.5.0</version>
+            <executions>
+              <execution>
+                <id>attach-javadocs</id>
+                <goals>
+                  <goal>jar</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+          <!-- detect os -->
+          <plugin>
+            <groupId>kr.motd.maven</groupId>
+            <artifactId>os-maven-plugin</artifactId>
+            <version>${os-maven-plugin.version}</version>
+            <executions>
+              <execution>
+                <goals>
+                  <goal>detect</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+
+          <plugin>
+            <groupId>org.sonatype.central</groupId>
+            <artifactId>central-publishing-maven-plugin</artifactId>
+            <version>0.6.0</version>
+            <extensions>true</extensions>
+            <configuration>
+              <publishingServerId>central</publishingServerId>
+              <autoPublish>true</autoPublish>
+            </configuration>
+          </plugin>
+
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-gpg-plugin</artifactId>
+            <version>3.2.7</version>
+            <executions>
+              <execution>
+                <id>sign-artifacts</id>
+                <phase>verify</phase>
+                <goals>
+                  <goal>sign</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+
+          <!-- generate java sources from proto -->
+          <plugin>
+            <groupId>org.xolstice.maven.plugins</groupId>
+            <artifactId>protobuf-maven-plugin</artifactId>
+            <version>${protobuf-maven-plugin.version}</version>
+            <configuration>
+              <protocArtifact>
+                com.google.protobuf:protoc:${protobuf-java.version}:exe:${os.detected.classifier}
+              </protocArtifact>
+              <pluginId>grpc-java</pluginId>
+              <pluginArtifact>
+                io.grpc:protoc-gen-grpc-java:${io.grpc.version}:exe:${os.detected.classifier}
+              </pluginArtifact>
+            </configuration>
+            <executions>
+              <execution>
+                <goals>
+                  <goal>compile</goal>
+                  <goal>compile-custom</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+
+          <!-- compile java -->
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-compiler-plugin</artifactId>
+            <version>${maven-compiler-plugin.version}</version>
+            <configuration>
+              <release>${maven-compiler-plugin.release}</release>
+            </configuration>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
+
+  <dependencies>
+    <!-- gRPC dependencies -->
+    <dependency>
+      <groupId>io.grpc</groupId>
+      <artifactId>grpc-netty-shaded</artifactId>
+      <version>${io.grpc.version}</version>
+      <scope>runtime</scope>
+    </dependency>
+    <dependency>
+      <groupId>io.grpc</groupId>
+      <artifactId>grpc-protobuf</artifactId>
+      <version>${io.grpc.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>io.grpc</groupId>
+      <artifactId>grpc-stub</artifactId>
+      <version>${io.grpc.version}</version>
+    </dependency>
+
+    <!-- Protobuf dependency -->
+    <dependency>
+      <groupId>com.google.protobuf</groupId>
+      <artifactId>protobuf-java</artifactId>
+      <version>${protobuf-java.version}</version>
+    </dependency>
+
+    <!-- Required for gRPC generated code -->
+    <dependency>
+      <groupId>javax.annotation</groupId>
+      <artifactId>javax.annotation-api</artifactId>
+      <version>${annotation-api.version}</version>
+    </dependency>
+
+    <!-- Test dependencies -->
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-api</artifactId>
+      <version>${org.junit.jupiter.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-engine</artifactId>
+      <version>${org.junit.jupiter.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <version>${mockito.version}</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+</project>
diff --git a/client/java/settings.xml b/client/java/settings.xml
new file mode 100644
index 00000000..47e86e76
--- /dev/null
+++ b/client/java/settings.xml
@@ -0,0 +1,24 @@
+<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
+          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+          xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">
+    <servers>
+        <server>
+            <id>central</id>
+            <username>${env.MAVEN_USERNAME}</username>
+            <password>${env.MAVEN_PASSWORD}</password>
+        </server>
+    </servers>
+    <profiles>
+        <profile>
+            <id>gpg-signing</id>
+            <properties>
+                <gpg.executable>gpg</gpg.executable>
+                <gpg.passphrase>${env.MAVEN_GPG_PASSPHRASE}</gpg.passphrase>
+                <gpg.args>--batch --pinentry-mode loopback</gpg.args>
+            </properties>
+        </profile>
+    </profiles>
+    <activeProfiles>
+        <activeProfile>gpg-signing</activeProfile>
+    </activeProfiles>
+</settings>
diff --git a/client/java/src/main/java/io/armadaproject/ArmadaClient.java b/client/java/src/main/java/io/armadaproject/ArmadaClient.java
new file mode 100644
index 00000000..d2be952e
--- /dev/null
+++ b/client/java/src/main/java/io/armadaproject/ArmadaClient.java
@@ -0,0 +1,99 @@
+package io.armadaproject;
+
+import api.EventGrpc;
+import api.EventOuterClass.EventStreamMessage;
+import api.EventOuterClass.JobSetRequest;
+import api.Health.HealthCheckResponse;
+import api.Health.HealthCheckResponse.ServingStatus;
+import api.Job.JobStatusRequest;
+import api.Job.JobStatusResponse;
+import api.JobsGrpc;
+import api.SubmitGrpc;
+import api.SubmitOuterClass.CancellationResult;
+import api.SubmitOuterClass.JobCancelRequest;
+import api.SubmitOuterClass.JobSubmitRequest;
+import api.SubmitOuterClass.JobSubmitResponse;
+import api.SubmitOuterClass.Queue;
+import api.SubmitOuterClass.QueueGetRequest;
+import api.SubmitOuterClass.StreamingQueueGetRequest;
+import api.SubmitOuterClass.StreamingQueueMessage;
+import com.google.protobuf.Empty;
+import io.grpc.ManagedChannel;
+import io.grpc.ManagedChannelBuilder;
+import io.grpc.Metadata;
+import io.grpc.stub.MetadataUtils;
+import io.grpc.stub.StreamObserver;
+import java.util.Iterator;
+
+public class ArmadaClient implements AutoCloseable {
+
+  private final ManagedChannel channel;
+
+  public ArmadaClient(String host, int port) {
+    channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
+  }
+
+  public ArmadaClient(String host, int port, String bearerToken) {
+    channel = ManagedChannelBuilder
+        .forAddress(host, port)
+        .useTransportSecurity()
+        .intercept(MetadataUtils.newAttachHeadersInterceptor(createAuthMetadata(bearerToken)))
+        .build();
+  }
+
+  private Metadata createAuthMetadata(String token) {
+    Metadata metadata = new Metadata();
+    metadata.put(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER),
+        "Bearer " + token);
+    return metadata;
+  }
+
+  public ServingStatus checkHealth() {
+    SubmitGrpc.SubmitBlockingStub submitBlockingStub = SubmitGrpc.newBlockingStub(channel);
+    HealthCheckResponse healthCheckResponse = submitBlockingStub.health(Empty.getDefaultInstance());
+    return healthCheckResponse.getStatus();
+  }
+
+  public JobSubmitResponse submitJob(JobSubmitRequest jobSubmitRequest) {
+    SubmitGrpc.SubmitBlockingStub submitBlockingStub = SubmitGrpc.newBlockingStub(channel);
+    return submitBlockingStub.submitJobs(jobSubmitRequest);
+  }
+
+  public CancellationResult cancelJob(JobCancelRequest jobCancelRequest) {
+    SubmitGrpc.SubmitBlockingStub submitBlockingStub = SubmitGrpc.newBlockingStub(channel);
+    return submitBlockingStub.cancelJobs(jobCancelRequest);
+  }
+
+  public Iterator<EventStreamMessage> getEvents(JobSetRequest jobSetRequest) {
+    EventGrpc.EventBlockingStub eventBlockingStub = EventGrpc.newBlockingStub(channel);
+    return eventBlockingStub.getJobSetEvents(jobSetRequest);
+  }
+
+  public void streamEvents(JobSetRequest jobSetRequest,
+      StreamObserver<EventStreamMessage> streamObserver) {
+    EventGrpc.EventStub eventStub = EventGrpc.newStub(channel);
+    eventStub.getJobSetEvents(jobSetRequest, streamObserver);
+  }
+
+  public Queue getQueue(QueueGetRequest queueGetRequest) {
+    SubmitGrpc.SubmitBlockingStub submitBlockingStub = SubmitGrpc.newBlockingStub(channel);
+    return submitBlockingStub.getQueue(queueGetRequest);
+  }
+
+  public Iterator<StreamingQueueMessage> getQueueInfo(
+      StreamingQueueGetRequest streamingQueueGetRequest) {
+    SubmitGrpc.SubmitBlockingStub submitBlockingStub = SubmitGrpc.newBlockingStub(channel);
+    return submitBlockingStub.getQueues(streamingQueueGetRequest);
+  }
+
+  public JobStatusResponse getJobStatus(JobStatusRequest jobStatusRequest) {
+    JobsGrpc.JobsBlockingStub jobsBlockingStub = JobsGrpc.newBlockingStub(channel);
+    return jobsBlockingStub.getJobStatus(jobStatusRequest);
+  }
+
+  @Override
+  public void close() {
+    channel.shutdown();
+  }
+
+}
diff --git a/client/java/src/main/java/io/armadaproject/examples/CancelJob.java b/client/java/src/main/java/io/armadaproject/examples/CancelJob.java
new file mode 100644
index 00000000..98b83b12
--- /dev/null
+++ b/client/java/src/main/java/io/armadaproject/examples/CancelJob.java
@@ -0,0 +1,27 @@
+package io.armadaproject.examples;
+
+import api.SubmitOuterClass.CancellationResult;
+import api.SubmitOuterClass.JobCancelRequest;
+import io.armadaproject.ArmadaClient;
+import java.util.logging.Logger;
+
+public class CancelJob {
+
+  private static final Logger LOG = Logger.getLogger(CancelJob.class.getName());
+
+  public static void main(String[] args) {
+    ArmadaClient armadaClient = new ArmadaClient("localhost", 30002);
+
+    String queueName = "example";
+    // TODO use @{link SubmitExample} to submit a job and get the job id
+    String jobId = "01je8sr94msnkcwvne7f41cynv";
+    JobCancelRequest jobCancelRequest = JobCancelRequest.newBuilder()
+        .setJobId(jobId)
+        .setQueue(queueName)
+        .setJobSetId(queueName)
+        .build();
+    CancellationResult cancellationResult = armadaClient.cancelJob(jobCancelRequest);
+    LOG.info("num of jobs cancelled: " + cancellationResult.getCancelledIdsCount());
+  }
+
+}
diff --git a/client/java/src/main/java/io/armadaproject/examples/CheckConnectionExample.java b/client/java/src/main/java/io/armadaproject/examples/CheckConnectionExample.java
new file mode 100644
index 00000000..751e1b23
--- /dev/null
+++ b/client/java/src/main/java/io/armadaproject/examples/CheckConnectionExample.java
@@ -0,0 +1,16 @@
+package io.armadaproject.examples;
+
+import api.Health.HealthCheckResponse.ServingStatus;
+import io.armadaproject.ArmadaClient;
+
+public class CheckConnectionExample {
+
+  public static void main(String[] args) {
+    ArmadaClient armadaClient = new ArmadaClient("localhost", 30002);
+
+    if (armadaClient.checkHealth() != ServingStatus.SERVING) {
+      throw new RuntimeException("armada server is not serving");
+    }
+  }
+
+}
diff --git a/client/java/src/main/java/io/armadaproject/examples/GetEvents.java b/client/java/src/main/java/io/armadaproject/examples/GetEvents.java
new file mode 100644
index 00000000..bd817d86
--- /dev/null
+++ b/client/java/src/main/java/io/armadaproject/examples/GetEvents.java
@@ -0,0 +1,25 @@
+package io.armadaproject.examples;
+
+import api.EventOuterClass.JobSetRequest;
+import io.armadaproject.ArmadaClient;
+import java.util.logging.Logger;
+
+public class GetEvents {
+
+  private static final Logger LOG = Logger.getLogger(GetEvents.class.getName());
+
+  public static void main(String[] args) {
+    ArmadaClient armadaClient = new ArmadaClient("localhost", 30002);
+
+    JobSetRequest jobSetRequest = JobSetRequest.newBuilder()
+        .setId("example")
+        .setQueue("example")
+        .setErrorIfMissing(true)
+        .build();
+
+    armadaClient.getEvents(jobSetRequest).forEachRemaining(e -> {
+      LOG.info("event:" + e);
+    });
+  }
+
+}
diff --git a/client/java/src/main/java/io/armadaproject/examples/GetJobStatus.java b/client/java/src/main/java/io/armadaproject/examples/GetJobStatus.java
new file mode 100644
index 00000000..dbdfd41a
--- /dev/null
+++ b/client/java/src/main/java/io/armadaproject/examples/GetJobStatus.java
@@ -0,0 +1,22 @@
+package io.armadaproject.examples;
+
+import api.Job.JobStatusRequest;
+import api.Job.JobStatusResponse;
+import io.armadaproject.ArmadaClient;
+import java.util.logging.Logger;
+
+public class GetJobStatus {
+
+  private static final Logger LOG = Logger.getLogger(GetJobStatus.class.getName());
+
+  public static void main(String[] args) {
+    ArmadaClient armadaClient = new ArmadaClient("localhost", 30002);
+
+    JobStatusResponse jobStatus = armadaClient.getJobStatus(JobStatusRequest.newBuilder()
+        .addJobIds("01je8sr94msnkcwvne7f41cynv")
+        .build());
+
+    LOG.info("job status: " + jobStatus);
+  }
+
+}
diff --git a/client/java/src/main/java/io/armadaproject/examples/GetQueue.java b/client/java/src/main/java/io/armadaproject/examples/GetQueue.java
new file mode 100644
index 00000000..6ea65447
--- /dev/null
+++ b/client/java/src/main/java/io/armadaproject/examples/GetQueue.java
@@ -0,0 +1,22 @@
+package io.armadaproject.examples;
+
+import api.SubmitOuterClass.Queue;
+import api.SubmitOuterClass.QueueGetRequest;
+import io.armadaproject.ArmadaClient;
+import java.util.logging.Logger;
+
+public class GetQueue {
+
+  private static final Logger LOG = Logger.getLogger(GetQueue.class.getName());
+
+  public static void main(String[] args) {
+    ArmadaClient armadaClient = new ArmadaClient("localhost", 30002);
+
+    Queue queue = armadaClient.getQueue(QueueGetRequest.newBuilder()
+        .setName("example")
+        .build());
+
+    LOG.info("queue:" + queue);
+  }
+
+}
diff --git a/client/java/src/main/java/io/armadaproject/examples/GetQueueInfo.java b/client/java/src/main/java/io/armadaproject/examples/GetQueueInfo.java
new file mode 100644
index 00000000..40cc892d
--- /dev/null
+++ b/client/java/src/main/java/io/armadaproject/examples/GetQueueInfo.java
@@ -0,0 +1,19 @@
+package io.armadaproject.examples;
+
+import api.SubmitOuterClass.StreamingQueueGetRequest;
+import io.armadaproject.ArmadaClient;
+import java.util.logging.Logger;
+
+public class GetQueueInfo {
+
+  private static final Logger LOG = Logger.getLogger(GetQueueInfo.class.getName());
+
+  public static void main(String[] args) {
+    ArmadaClient armadaClient = new ArmadaClient("localhost", 30002);
+
+    armadaClient.getQueueInfo(StreamingQueueGetRequest.newBuilder().build()).forEachRemaining(q -> {
+      LOG.info("queue:" + q);
+    });
+  }
+
+}
diff --git a/client/java/src/main/java/io/armadaproject/examples/StreamEvents.java b/client/java/src/main/java/io/armadaproject/examples/StreamEvents.java
new file mode 100644
index 00000000..df0a2c8d
--- /dev/null
+++ b/client/java/src/main/java/io/armadaproject/examples/StreamEvents.java
@@ -0,0 +1,72 @@
+package io.armadaproject.examples;
+
+import api.EventOuterClass.EventStreamMessage;
+import api.EventOuterClass.JobSetRequest;
+import io.armadaproject.ArmadaClient;
+import io.grpc.stub.StreamObserver;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.ConsoleHandler;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+public class StreamEvents {
+
+  private static final Logger LOG = Logger.getLogger(StreamEvents.class.getName());
+
+  static {
+    // Set the log level to FINE
+    LOG.setLevel(Level.FINE);
+
+    // Configure the console handler to output logs at FINE level
+    ConsoleHandler consoleHandler = new ConsoleHandler();
+    consoleHandler.setLevel(Level.FINE);
+    LOG.addHandler(consoleHandler);
+  }
+
+  public static void main(String[] args) {
+    ExecutorService executorService = Executors.newSingleThreadExecutor();
+
+    try (ArmadaClient armadaClient = new ArmadaClient("localhost", 30002)) {
+
+      JobSetRequest jobSetRequest = JobSetRequest.newBuilder()
+          .setId("job-set-1")
+          .setQueue("example")
+          .setWatch(true)
+          .build();
+
+      executorService.submit(() -> {
+        StreamObserver<EventStreamMessage> streamObserver = new StreamObserver<>() {
+          @Override
+          public void onNext(EventStreamMessage value) {
+            LOG.log(Level.FINE, "event: " + value);
+          }
+
+          @Override
+          public void onError(Throwable t) {
+            LOG.log(Level.SEVERE, "error: " + t);
+          }
+
+          @Override
+          public void onCompleted() {
+            LOG.info("completed");
+          }
+        };
+
+        armadaClient.streamEvents(jobSetRequest, streamObserver);
+      });
+
+      // Keep the main thread alive to continue receiving events
+      try {
+        executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+      }
+    } catch (Exception e) {
+      LOG.severe("Failed to close client: " + e);
+    } finally {
+      executorService.shutdown();
+    }
+  }
+}
\ No newline at end of file
diff --git a/client/java/src/main/java/io/armadaproject/examples/SubmitJob.java b/client/java/src/main/java/io/armadaproject/examples/SubmitJob.java
new file mode 100644
index 00000000..8a41a53e
--- /dev/null
+++ b/client/java/src/main/java/io/armadaproject/examples/SubmitJob.java
@@ -0,0 +1,55 @@
+package io.armadaproject.examples;
+
+import api.SubmitOuterClass.JobSubmitRequest;
+import api.SubmitOuterClass.JobSubmitRequestItem;
+import api.SubmitOuterClass.JobSubmitResponse;
+import io.armadaproject.ArmadaClient;
+import java.util.List;
+import java.util.logging.Logger;
+import k8s.io.api.core.v1.Generated.Container;
+import k8s.io.api.core.v1.Generated.PodSpec;
+import k8s.io.api.core.v1.Generated.ResourceRequirements;
+import k8s.io.apimachinery.pkg.api.resource.Generated.Quantity;
+
+public class SubmitJob {
+
+  private static final Logger LOG = Logger.getLogger(SubmitJob.class.getName());
+
+  public static void main(String[] args) {
+    ArmadaClient armadaClient = new ArmadaClient("localhost", 30002);
+
+    String queueName = "example";
+
+    JobSubmitRequest jobSubmitRequest = JobSubmitRequest.newBuilder()
+        .setQueue(queueName)
+        .setJobSetId(queueName)
+        .addJobRequestItems(JobSubmitRequestItem.newBuilder()
+            .setNamespace("default")
+            .setPriority(0.00d)
+            .addPodSpecs(PodSpec.newBuilder()
+                .setPriorityClassName("armada-default")
+                .setTerminationGracePeriodSeconds(0L)
+                .setRestartPolicy("Never")
+                .addContainers(Container.newBuilder()
+                    .setName("jenkins-agent-0")
+                    .setImage("jenkins/inbound-agent:latest-jdk21")
+                    .addCommand("sh")
+                    .addAllArgs(List.of("-c", "tail -f /dev/null"))
+                    .setResources(ResourceRequirements.newBuilder()
+                        .putLimits("memory", Quantity.newBuilder().setString("512Mi").build())
+                        .putLimits("cpu", Quantity.newBuilder().setString("2").build())
+                        .putRequests("memory", Quantity.newBuilder().setString("512Mi").build())
+                        .putRequests("cpu", Quantity.newBuilder().setString("2").build())
+                        .build())
+                    .build())
+                .build())
+            .build())
+        .build();
+
+    JobSubmitResponse jobSubmitResponse = armadaClient.submitJob(jobSubmitRequest);
+
+    String jobId = jobSubmitResponse.getJobResponseItems(0).getJobId();
+    LOG.info("jobId: " + jobId);
+  }
+
+}
diff --git a/magefiles/config.go b/magefiles/config.go
index f7048617..fb064997 100644
--- a/magefiles/config.go
+++ b/magefiles/config.go
@@ -10,6 +10,7 @@ type BuildConfig struct {
 	DockerRegistries       map[string]string `json:"dockerRegistries"`
 	PythonBuilderBaseImage string            `json:"pythonBuilderBaseImage"`
 	ScalaBuilderBaseImage  string            `json:"scalaBuilderBaseImage"`
+	JavaBuilderBaseImage   string            `json:"javaBuilderBaseImage"`
 }
 
 func getBuildConfig() (BuildConfig, error) {
diff --git a/magefiles/java.go b/magefiles/java.go
new file mode 100644
index 00000000..34d16abc
--- /dev/null
+++ b/magefiles/java.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/magefile/mage/mg"
+)
+
+const (
+	javaBuilderImageName = "armada-java-client-builder"
+	dockerfilePath       = "./build/java-client/Dockerfile"
+	buildDir             = "/build"
+)
+
+// BuildJava Build armada Java client.
+func BuildJava() error {
+	mg.Deps(BootstrapProto)
+
+	buildConfig, err := getBuildConfig()
+	if err != nil {
+		return err
+	}
+
+	err = dockerBuildImage(NewDockerBuildConfig(buildConfig.JavaBuilderBaseImage),
+		javaBuilderImageName, dockerfilePath)
+	if err != nil {
+		return err
+	}
+
+	wd, err := os.Getwd()
+	if err != nil {
+		return err
+	}
+
+	return dockerRun("run",
+		"--rm",
+		"-v", fmt.Sprintf("%s:%s", wd, buildDir),
+		"-w", buildDir,
+		javaBuilderImageName,
+	)
+}
diff --git a/magefiles/main.go b/magefiles/main.go
index ba422edb..c7edb993 100644
--- a/magefiles/main.go
+++ b/magefiles/main.go
@@ -214,7 +214,7 @@ func LocalDev(arg string) error {
 	case "minimal":
 		mg.Deps(mg.F(goreleaserMinimalRelease, "bundle", "lookout-bundle"), Kind, downloadDependencyImages)
 	case "full":
-		mg.Deps(BuildPython, BuildScala, mg.F(BuildDockers, "bundle, lookout-bundle"), Kind, downloadDependencyImages)
+		mg.Deps(BuildPython, BuildScala, BuildJava, mg.F(BuildDockers, "bundle, lookout-bundle"), Kind, downloadDependencyImages)
 	case "no-build", "debug":
 		mg.Deps(Kind, downloadDependencyImages)
 	default:
diff --git a/scripts/build-java-client.sh b/scripts/build-java-client.sh
new file mode 100644
index 00000000..e1e63e92
--- /dev/null
+++ b/scripts/build-java-client.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+# This script is intended to be run under the docker container at $ARMADADIR/build/java-client
+
+ROOT=$(pwd)
+JDIR=client/java
+cd $ROOT/$JDIR
+
+# Build the Java client using Maven
+mvn clean install
+
+if [ $? -eq 0 ]; then
+  jarfile=$(find target -type f -name 'java-client*.jar' | sort | head -n1)
+  if [[ $jarfile ]]; then
+    echo "" > /dev/stderr
+    jarfile=$(echo $jarfile | sed -e s%^\./%%)
+    echo "Armada Java client jar file written to $JDIR/$jarfile" > /dev/stderr
+  fi
+else
+  echo "Maven build exited with exit code $?" > /dev/stderr
+fi



Test patch:
diff --git a/client/java/src/test/java/io/armadaproject/ArmadaClientTest.java b/client/java/src/test/java/io/armadaproject/ArmadaClientTest.java
new file mode 100644
index 00000000..5c001419
--- /dev/null
+++ b/client/java/src/test/java/io/armadaproject/ArmadaClientTest.java
@@ -0,0 +1,110 @@
+package io.armadaproject;
+
+import api.Health.HealthCheckResponse.ServingStatus;
+import api.Job.JobStatusRequest;
+import api.Job.JobStatusResponse;
+import api.SubmitOuterClass;
+import api.SubmitOuterClass.*;
+import io.grpc.StatusRuntimeException;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class ArmadaClientTest {
+    private static final String ARMADA_CLIENT_HOST = "localhost";
+    private static final int TEST_PORT = 12345;
+    private static ArmadaClient armadaClient;
+    private static MockServer mockServer;
+
+
+    @BeforeAll
+    public static void setUp() throws IOException {
+        mockServer = new MockServer(TEST_PORT);
+        mockServer.start();
+        armadaClient = new ArmadaClient(ARMADA_CLIENT_HOST, TEST_PORT);
+    }
+
+    @AfterAll
+    public static void tearDown() throws Exception {
+        mockServer.stop();
+        armadaClient.close();
+    }
+
+    @Test
+    public void testCheckHealth() {
+        ServingStatus status = armadaClient.checkHealth();
+        assertEquals(ServingStatus.SERVING, status);
+    }
+
+    @Test
+    public void testSubmitJobs() {
+        String jobSetId = "test-job-set";
+        String queueName = "example";
+
+        JobSubmitRequest submitRequest = JobSubmitRequest.newBuilder()
+                .setQueue(queueName)
+                .setJobSetId(jobSetId)
+                .addJobRequestItems(JobSubmitRequestItem.newBuilder().build())
+                .build();
+
+        JobSubmitResponse submitResponse = armadaClient.submitJob(submitRequest);
+        assertNotNull(submitResponse);
+        assertEquals(1, submitResponse.getJobResponseItemsCount());
+
+        String jobId = submitResponse.getJobResponseItems(0).getJobId();
+
+        // Verify the job status
+        JobStatusRequest statusRequest = JobStatusRequest.newBuilder().addJobIds(jobId).build();
+        JobStatusResponse statusResponse = armadaClient.getJobStatus(statusRequest);
+        assertNotNull(statusResponse);
+        assertEquals(SubmitOuterClass.JobState.RUNNING, statusResponse.getJobStatesMap().get(jobId));
+    }
+
+    @Test
+    public void testCancelJobs() {
+        String jobSetId = "test-job-set";
+        String queueName = "example";
+
+        JobSubmitRequest submitRequest = JobSubmitRequest.newBuilder()
+                .setQueue(queueName)
+                .setJobSetId(jobSetId)
+                .addJobRequestItems(JobSubmitRequestItem.newBuilder().build())
+                .build();
+
+        JobSubmitResponse submitResponse = armadaClient.submitJob(submitRequest);
+        String jobId = submitResponse.getJobResponseItems(0).getJobId();
+
+        // Cancel the job
+        armadaClient.cancelJob(JobCancelRequest.newBuilder().addJobIds(jobId).build());
+
+        // job status should be CANCELLED
+        JobStatusRequest statusRequest = JobStatusRequest.newBuilder().addJobIds(jobId).build();
+        JobStatusResponse statusResponse = armadaClient.getJobStatus(statusRequest);
+        assertEquals(SubmitOuterClass.JobState.CANCELLED, statusResponse.getJobStatesMap().get(jobId));
+    }
+
+    @Test
+    public void testGetQueue() {
+        String queueName = "example";
+        QueueGetRequest getRequest = QueueGetRequest.newBuilder().setName(queueName).build();
+        Queue retrievedQueue = armadaClient.getQueue(getRequest);
+        assertEquals(queueName, retrievedQueue.getName());
+    }
+
+    @Test
+    public void testSubmitJobsToNonExistentQueue() {
+        String nonExistentQueueName = "non-existent-queue";
+
+        JobSubmitRequest submitRequest = JobSubmitRequest.newBuilder()
+                .setQueue(nonExistentQueueName)
+                .setJobSetId("test-job-set")
+                .addJobRequestItems(JobSubmitRequestItem.newBuilder().build())
+                .build();
+
+        assertThrows(StatusRuntimeException.class, () -> armadaClient.submitJob(submitRequest));
+    }
+}
\ No newline at end of file
diff --git a/client/java/src/test/java/io/armadaproject/MockServer.java b/client/java/src/test/java/io/armadaproject/MockServer.java
new file mode 100644
index 00000000..0e160c14
--- /dev/null
+++ b/client/java/src/test/java/io/armadaproject/MockServer.java
@@ -0,0 +1,149 @@
+package io.armadaproject;
+
+import api.*;
+import com.google.protobuf.Empty;
+import io.grpc.Server;
+import io.grpc.ServerBuilder;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import io.grpc.stub.StreamObserver;
+
+import java.io.IOException;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class MockServer {
+    private final int port;
+    private Server server;
+
+    // Stateful maps for queues, jobs, and job statuses
+    private final ConcurrentHashMap<String, SubmitOuterClass.Queue> queueMap = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<String, SubmitOuterClass.Job> jobMap = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<String, SubmitOuterClass.JobState> statusMap = new ConcurrentHashMap<>();
+
+    private static final String DEFAULT_QUEUE = "example";
+
+    public MockServer(int port) {
+        this.port = port;
+        // Initialize the queueMap with the fixed queue "example"
+        queueMap.put(DEFAULT_QUEUE, SubmitOuterClass.Queue.newBuilder().setName(DEFAULT_QUEUE).build());
+    }
+
+    public void start() throws IOException {
+        server = ServerBuilder.forPort(port)
+                .addService(new MockEventService())
+                .addService(new MockSubmitService(queueMap, jobMap, statusMap))
+                .addService(new MockJobsService(statusMap))
+                .build()
+                .start();
+    }
+
+    public void stop() {
+        if (server != null) {
+            server.shutdown();
+        }
+    }
+
+    private static class MockEventService extends EventGrpc.EventImplBase {
+        @Override
+        public void getJobSetEvents(EventOuterClass.JobSetRequest request, StreamObserver<EventOuterClass.EventStreamMessage> responseObserver) {
+            responseObserver.onNext(EventOuterClass.EventStreamMessage.getDefaultInstance());
+            responseObserver.onCompleted();
+        }
+    }
+
+    private static class MockSubmitService extends SubmitGrpc.SubmitImplBase {
+        private final ConcurrentHashMap<String, SubmitOuterClass.Queue> queueMap;
+        private final ConcurrentHashMap<String, SubmitOuterClass.Job> jobMap;
+        private final ConcurrentHashMap<String, SubmitOuterClass.JobState> statusMap;
+
+        public MockSubmitService(
+                ConcurrentHashMap<String, SubmitOuterClass.Queue> queueMap,
+                ConcurrentHashMap<String, SubmitOuterClass.Job> jobMap,
+                ConcurrentHashMap<String, SubmitOuterClass.JobState> statusMap) {
+            this.queueMap = queueMap;
+            this.jobMap = jobMap;
+            this.statusMap = statusMap;
+        }
+
+        @Override
+        public void health(Empty request, StreamObserver<Health.HealthCheckResponse> responseObserver) {
+            responseObserver.onNext(Health.HealthCheckResponse.newBuilder()
+                    .setStatus(Health.HealthCheckResponse.ServingStatus.SERVING)
+                    .build());
+            responseObserver.onCompleted();
+        }
+
+        @Override
+        public void submitJobs(SubmitOuterClass.JobSubmitRequest request, StreamObserver<SubmitOuterClass.JobSubmitResponse> responseObserver) {
+            String queueName = request.getQueue();
+
+            // Check if the queue exists
+            if (!queueMap.containsKey(queueName)) {
+                responseObserver.onError(new StatusRuntimeException(Status.NOT_FOUND.withDescription("Queue not found")));
+                return;
+            }
+
+            String jobId = "job-" + System.currentTimeMillis();
+            SubmitOuterClass.Job job = SubmitOuterClass.Job.newBuilder()
+                    .setJobSetId(request.getJobSetId())
+                    .build();
+            jobMap.put(jobId, job);
+            statusMap.put(jobId, SubmitOuterClass.JobState.RUNNING);
+
+            SubmitOuterClass.JobSubmitResponse response = SubmitOuterClass.JobSubmitResponse.newBuilder()
+                    .addJobResponseItems(SubmitOuterClass.JobSubmitResponseItem.newBuilder().setJobId(jobId).build()).build();
+            responseObserver.onNext(response);
+            responseObserver.onCompleted();
+        }
+
+        @Override
+        public void getQueue(SubmitOuterClass.QueueGetRequest request, StreamObserver<SubmitOuterClass.Queue> responseObserver) {
+            // Return the queue if it exists in the queueMap
+            SubmitOuterClass.Queue queue = queueMap.get(request.getName());
+            if (queue != null) {
+                responseObserver.onNext(queue);
+                responseObserver.onCompleted();
+            } else {
+                responseObserver.onError(new StatusRuntimeException(Status.NOT_FOUND.withDescription("Queue not found")));
+            }
+        }
+
+        @Override
+        public void cancelJobs(SubmitOuterClass.JobCancelRequest request, StreamObserver<SubmitOuterClass.CancellationResult> responseObserver) {
+            SubmitOuterClass.CancellationResult.Builder resultBuilder = SubmitOuterClass.CancellationResult.newBuilder();
+
+            // Cancel each job in the request
+            for (String jobId : request.getJobIdsList()) {
+                if (statusMap.containsKey(jobId)) {
+                    statusMap.put(jobId, SubmitOuterClass.JobState.CANCELLED);
+                    resultBuilder.addCancelledIds(jobId);
+                }
+            }
+
+            // Return the cancellation result
+            responseObserver.onNext(resultBuilder.build());
+            responseObserver.onCompleted();
+        }
+    }
+
+    private static class MockJobsService extends JobsGrpc.JobsImplBase {
+        private final ConcurrentHashMap<String, SubmitOuterClass.JobState> statusMap;
+
+        public MockJobsService(ConcurrentHashMap<String, SubmitOuterClass.JobState> statusMap) {
+            this.statusMap = statusMap;
+        }
+
+        @Override
+        public void getJobStatus(Job.JobStatusRequest request, StreamObserver<Job.JobStatusResponse> responseObserver) {
+            Job.JobStatusResponse.Builder responseBuilder = Job.JobStatusResponse.newBuilder();
+            for (String jobId : request.getJobIdsList()) {
+                SubmitOuterClass.JobState state = statusMap.get(jobId);
+                if (state != null) {
+                    responseBuilder.putJobStates(jobId, state);
+                }
+            }
+            responseObserver.onNext(responseBuilder.build());
+            responseObserver.onCompleted();
+        }
+    }
+}
\ No newline at end of file