diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..69bdfe3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: ci + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # fetch tags - tags are needed for versionPolicyCheck binary and source compat checks + fetch-tags: true + - uses: coursier/cache-action@v6 + - uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: temurin + cache: sbt + - uses: sbt/setup-sbt@v1 + - name: sbt build + run: sbt clean build diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 0000000..d6837eb --- /dev/null +++ b/.sbtopts @@ -0,0 +1,8 @@ +# for using sbt-java-formatter on newer JDKs: +-J--add-opens=java.base/java.lang=ALL-UNNAMED +-J--add-opens=java.base/java.util=ALL-UNNAMED +-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED +-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED +-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED +-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED +-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..f5c48ca --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,99 @@ +# Main goals: +# - nicer commit diffs (trailing commas, no alignment for pattern matching, force new lines) +# - better interop with default IntelliJ IDEA setup (matching import and modifiers sorting logic) +# - better developer experience on laptop screens (like 16' MBPs) with IntelliJ IDEA (line wraps) + +version = 3.9.9 + +runner.dialect = scala213source3 +fileOverride { + "glob:**/scala-3/**/*.scala" {runner.dialect = scala3} +} + +# only format files tracked by git +project.git = true + +maxColumn = 110 +trailingCommas = always + +preset = default +# do not align to make nicer commit diffs +align.preset = none + +indent { + # altering defnSite and extendSite to have this: + # final class MyErr extends RuntimeException( + # "super error message", + # ) + # instead of this: + # final class MyErr extends RuntimeException( + # "super error message", + # ) + defnSite = 2 + extendSite = 0 +} + +spaces { + # makes string interpolation with curlies more visually distinct + inInterpolatedStringCurlyBraces = true +} + +newlines { + # keep author new lines where possible + source = keep + # force new line after "(implicit" for multi-line arg lists + implicitParamListModifierForce = [after] + avoidForSimpleOverflow = [ + tooLong, # if the line would be too long even after newline inserted, do nothing + slc, # do nothing if overflow caused by single line comment + ] +} + +verticalMultiline { + atDefnSite = true + arityThreshold = 4 # more than 3 args in a list will be turned vertical + newlineAfterOpenParen = true # for nicer commit diffs +} + +# for nicer commit diffs - forces new line before last parenthesis: +# class MyCls( +# arg1: String, +# arg2: String, +# ) extends MyTrait { +# +# without it: +# class MyCls( +# arg1: String, +# arg2: String) extends MyTrait { +danglingParentheses.exclude = [] + +docstrings { + # easier to view diffs in IDEA on 16' MBP screen if docs max line are shorter than code + wrapMaxColumn = 90 + # next settings make it similar to the default IDEA javadoc formatting + style = Asterisk + oneline = unfold + blankFirstLine = unfold +} + +rewrite.rules = [ + Imports, + RedundantParens, + SortModifiers, + prefercurlyfors, +] + +# put visibility modifier first +rewrite.sortModifiers.preset = styleGuide + +# Import sorting as similar as possible to scalafix's "OrganizeImports.preset = INTELLIJ_2020_3". +# Scalafix is not used as its commands mess up "all .." build aliases and it takes long time to run, +# while its code semantic based features are not needed here. +# I.e. detection of unused imports is done with Scala compiler options. +rewrite.imports { + sort = ascii + groups = [ + [".*"], + ["java\\..*", "javax\\..*", "scala\\..*"], + ] +} diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..7b1a030 --- /dev/null +++ b/build.sbt @@ -0,0 +1,75 @@ +import Dependencies.* +import sbt.* + +ThisBuild / organization := "com.evolution.jgrpc.tools" +ThisBuild / startYear := Some(2025) +ThisBuild / homepage := Some(url("https://github.com/evolution-gaming/grpc-java-tools")) +ThisBuild / licenses := Seq(("MIT", url("https://opensource.org/licenses/MIT"))) +ThisBuild / organizationName := "Evolution" +ThisBuild / organizationHomepage := Some(url("https://evolution.com")) + +// Maven Central requires in published pom.xml files +ThisBuild / developers := List( + Developer( + id = "migesok", + name = "Mikhail Sokolov", + email = "mikhail.g.sokolov@gmail.com", + url = url("https://github.com/migesok"), + ), +) + +ThisBuild / scmInfo := Some(ScmInfo( + browseUrl = url("https://github.com/evolution-gaming/grpc-java-tools"), + connection = "git@github.com:evolution-gaming/grpc-java-tools.git", +)) + +// not sure if bincompat check works for Java code, put it here just in case +ThisBuild / versionPolicyIntention := Compatibility.BinaryCompatible + +// this is a Java project, setting a fixed Scala version just in case +ThisBuild / scalaVersion := "2.13.16" + +// setting pure-Java module build settings +ThisBuild / crossPaths := false // drop off Scala suffix from artifact names. +ThisBuild / autoScalaLibrary := false // exclude scala-library from dependencies +ThisBuild / javacOptions := Seq("-source", "17", "-target", "17", "-Werror", "-Xlint:all") +ThisBuild / doc / javacOptions := Seq("-source", "17", "-Xdoclint:all", "-Werror") + +// common test dependencies: +ThisBuild / libraryDependencies ++= Seq( + // to be able to run JUnit 5+ tests: + "com.github.sbt.junit" % "jupiter-interface" % JupiterKeys.jupiterVersion.value, + Slf4j.simple, +).map(_ % Test) + +// common compile dependencies: +ThisBuild / libraryDependencies ++= Seq( + jspecify, // JSpecify null-check annotations +) + +lazy val root = project.in(file(".")) + .settings( + name := "grpc-java-tools-root", + description := "Evolution grpc-java tools - root", + publish / skip := true, + ) + .aggregate( + k8sDnsNameResolver, + ) + +lazy val k8sDnsNameResolver = project.in(file("k8s-dns-name-resolver")) + .settings( + name := "k8s-dns-name-resolver", + description := "Evolution grpc-java tools - DNS-based name resolver for Kubernetes services", + libraryDependencies ++= Seq( + Grpc.api, + Slf4j.api, + dnsJava, + ), + ) + +addCommandAlias("fmt", "all scalafmtAll scalafmtSbt javafmtAll") +addCommandAlias( + "build", + "all scalafmtCheckAll scalafmtSbtCheck javafmtCheckAll versionPolicyCheck Compile/doc test", +) diff --git a/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/K8sDnsNameResolver.java b/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/K8sDnsNameResolver.java new file mode 100644 index 0000000..e66fe27 --- /dev/null +++ b/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/K8sDnsNameResolver.java @@ -0,0 +1,193 @@ +package com.evolution.jgrpc.tools.k8sdns; + +import static java.lang.Math.max; +import static java.lang.String.format; + +import com.google.common.net.InetAddresses; +import io.grpc.*; +import io.grpc.SynchronizationContext.ScheduledHandle; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import org.jspecify.annotations.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xbill.DNS.Name; +import org.xbill.DNS.Record; +import org.xbill.DNS.Type; +import org.xbill.DNS.lookup.LookupResult; +import org.xbill.DNS.lookup.LookupSession; + +/* package */ final class K8sDnsNameResolver extends NameResolver { + + private static final Logger logger = LoggerFactory.getLogger(K8sDnsNameResolver.class); + + private final ParsedDnsTargetUri targetUri; + private final long refreshIntervalSeconds; + private final SynchronizationContext syncCtx; + private final ScheduledExecutorService scheduledExecutor; + private final LookupSession dnsLookupSession; + + @Nullable private Listener listener = null; + + @Nullable private ScheduledHandle scheduledRefreshTask = null; + @Nullable private SuccessResult lastSuccessfulResult = null; + private boolean refreshing = false; + + private record SuccessResult(List addresses, Instant receiveTime) {} + + /* package */ K8sDnsNameResolver( + ParsedDnsTargetUri targetUri, + int refreshIntervalSeconds, + SynchronizationContext syncCtx, + ScheduledExecutorService scheduledExecutor) { + this.targetUri = targetUri; + this.refreshIntervalSeconds = refreshIntervalSeconds; + this.syncCtx = syncCtx; + this.scheduledExecutor = scheduledExecutor; + this.dnsLookupSession = + LookupSession.defaultBuilder().searchPath(targetUri.host()).clearCaches().build(); + } + + @Override + public String getServiceAuthority() { + return this.targetUri.authority(); + } + + @Override + public void shutdown() { + if (this.scheduledRefreshTask != null) { + this.scheduledRefreshTask.cancel(); + } + } + + @Override + public void start(Listener listener) { + this.listener = listener; + startScheduledRefreshTask(0L); + } + + @Override + public void refresh() { + if (this.scheduledRefreshTask == null) { + // this means the last attempt failed, and we are getting retried by the client + + var initialDelayMs = 0L; + if (this.lastSuccessfulResult != null) { + var now = Instant.now(); + var minExpectedRefreshTs = + this.lastSuccessfulResult.receiveTime.plusSeconds(this.refreshIntervalSeconds); + initialDelayMs = max(0L, Duration.between(now, minExpectedRefreshTs).toMillis()); + } + + startScheduledRefreshTask(initialDelayMs); + } + } + + private void startScheduledRefreshTask(long initialDelayMs) { + this.scheduledRefreshTask = + this.syncCtx.scheduleWithFixedDelay( + this::refreshInner, + initialDelayMs, + this.refreshIntervalSeconds * 1000, // delay + TimeUnit.MILLISECONDS, + this.scheduledExecutor); + } + + private void refreshInner() { + if (!this.refreshing) { + this.refreshing = true; + resolveAllAsync( + (addresses, err) -> { + try { + if (err != null) { + handleResolutionFailure(err); + } else if (addresses != null && !addresses.isEmpty()) { + handleResolutionSuccess(addresses); + } else { + // listener.onAddresses with an empty list is equivalent to listener.onError + // and should be retried by the client, as per NameResolver protocol + handleResolutionFailure(new UnknownHostException(this.targetUri.hostStr())); + } + } finally { + this.refreshing = false; + } + }); + } + } + + private void handleResolutionFailure(Throwable err) { + /* + NameResolver contract specifies that the client handles retries and their frequency. + So in case of failure, we need to cancel internal reoccurring refresh ticks and rely on refresh method + invoked externally. + */ + if (this.scheduledRefreshTask != null) { + this.scheduledRefreshTask.cancel(); + this.scheduledRefreshTask = null; + } + getListener() + .onError( + Status.UNAVAILABLE + .withDescription(format("Unable to resolve host %s", this.targetUri.hostStr())) + .withCause(err)); + } + + private void handleResolutionSuccess(List addresses) { + // do not notify if addresses didn't change + if (this.lastSuccessfulResult == null + // the addresses list is always sorted here and contains only unique values + || !this.lastSuccessfulResult.addresses.equals(addresses)) { + var addrGroups = addresses.stream().map(this::mkAddressGroup).toList(); + getListener().onAddresses(addrGroups, Attributes.EMPTY); + } + + this.lastSuccessfulResult = new SuccessResult(addresses, Instant.now()); + } + + private EquivalentAddressGroup mkAddressGroup(InetAddress addr) { + return new EquivalentAddressGroup(new InetSocketAddress(addr, this.targetUri.port())); + } + + // callback is executed under syncCtx + private void resolveAllAsync( + BiConsumer<@Nullable List, ? super @Nullable Throwable> cb) { + final var dnsLookupAsyncResult = this.dnsLookupSession.lookupAsync(Name.empty, Type.A); + dnsLookupAsyncResult + .thenApply( + (result) -> { + logger.debug("DNS lookup result: {}", result); + var records = + Optional.ofNullable(result).map(LookupResult::getRecords).orElse(List.of()); + return records.stream() + .map(Record::rdataToString) + .distinct() + .sorted() // make sure that result comparison does not depend on order + .map(InetAddresses::forString) + .toList(); + }) + .whenComplete( + (addresses, err) -> + this.syncCtx.execute( + () -> { + if (err != null) { + logger.error("DNS lookup failed", err); + } + cb.accept(addresses, err); + })); + } + + private Listener getListener() { + if (this.listener == null) { + throw new IllegalStateException("listener not set"); + } + return this.listener; + } +} diff --git a/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/K8sDnsNameResolverProvider.java b/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/K8sDnsNameResolverProvider.java new file mode 100644 index 0000000..9cf6e82 --- /dev/null +++ b/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/K8sDnsNameResolverProvider.java @@ -0,0 +1,157 @@ +package com.evolution.jgrpc.tools.k8sdns; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import io.grpc.NameResolver; +import io.grpc.NameResolver.Args; +import io.grpc.NameResolverProvider; +import java.net.URI; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * {@link NameResolverProvider} for DNS-based GRPC service discovery in Kubernetes. + * + *

It is an alternative to {@code io.grpc.internal.DnsNameResolver} geared towards better support + * for resolving addresses of Kubernetes headless service pods by hostname. + * + *

The main improvement over {@code DnsNameResolver} is that this resolver implements live + * watching of the set of ready pods and notifies the channel when it changes. + * + *

This resolver uses the dnsjava library for + * executing queries instead of the JDK built-in capabilities. This allows executing DNS queries + * with caching disabled without changing JVM-wide settings. + * + *

DNS queries are repeated every 10 seconds by default. The interval can be adjusted using + * {@link K8sDnsNameResolverProvider#setRefreshIntervalSeconds(int)}. + * + *

Only A-records are supported. + * + *

Example target URIs: + * + *

    + *
  • {@code k8s-dns://my-svc.my-namespace.svc.my-cluster.local} (default port) + *
  • {@code k8s-dns:///my-svc.my-namespace.svc.my-cluster.local} (default port) + *
  • {@code k8s-dns://my-svc.my-namespace.svc.my-cluster.local:8080} + *
  • {@code k8s-dns:///my-svc.my-namespace.svc.my-cluster.local:8080} + *
+ * + *

If you wish to use a different URI schema, you can create a custom instance using {@link + * K8sDnsNameResolverProvider#K8sDnsNameResolverProvider(String, int, int)} and register it + * manually. + * + *

This class is thread-safe. + * + * @see NameResolverProvider + */ +public final class K8sDnsNameResolverProvider extends NameResolverProvider { + + /** The default URI scheme handled by this provider. */ + public static final String DEFAULT_SCHEME = "k8s-dns"; + + /** + * The default interval in seconds between DNS refresh operations. + * + *

The default Kubernetes CoreDNS TTL is 5 seconds. Our default refresh interval is 2x that. + */ + public static final int DEFAULT_REFRESH_INTERVAL_SECONDS = 10; + + /** + * The default priority for this name resolver provider. + * + *

5 is the recommended default from NameResolverProvider documentation. + * + * @see io.grpc.NameResolverProvider#priority() + */ + public static final int DEFAULT_PRIORITY = 5; + + private final int priority; + private final String scheme; + + private volatile int refreshIntervalSeconds = DEFAULT_REFRESH_INTERVAL_SECONDS; + + /** Creates a new K8sDnsNameResolverProvider with default configuration. */ + public K8sDnsNameResolverProvider() { + this.scheme = DEFAULT_SCHEME; + this.priority = DEFAULT_PRIORITY; + } + + /** + * Creates a new K8sDnsNameResolverProvider with custom configuration. + * + *

Use this constructor to register K8sDnsNameResolverProvider with a custom URI scheme. + * + * @param scheme the URI scheme this provider handles, non-null; {@link + * K8sDnsNameResolverProvider#DEFAULT_SCHEME} + * @param priority the priority of this provider, must be [0, 10]; {@link + * K8sDnsNameResolverProvider#DEFAULT_PRIORITY} + * @param refreshIntervalSeconds the interval in seconds between DNS refreshes, must be positive; + * {@link K8sDnsNameResolverProvider#DEFAULT_REFRESH_INTERVAL_SECONDS} + * @see io.grpc.NameResolverProvider#priority() resolver provider priority + * @see K8sDnsNameResolverProvider#newNameResolver(URI, Args) URI scheme handling + */ + public K8sDnsNameResolverProvider(String scheme, int priority, int refreshIntervalSeconds) { + this.scheme = requireNonNull(scheme, "scheme must not be null"); + this.priority = validatePriority(priority); + this.refreshIntervalSeconds = validateRefreshInterval(refreshIntervalSeconds); + } + + /** + * Changes DNS query refresh interval. + * + *

Must be called before creating any channels that use this resolver's target URI schema. + * + * @param refreshIntervalSeconds the refresh interval in seconds, must be positive + */ + public void setRefreshIntervalSeconds(int refreshIntervalSeconds) { + this.refreshIntervalSeconds = validateRefreshInterval(refreshIntervalSeconds); + } + + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return this.priority; + } + + @Override + public String getDefaultScheme() { + return this.scheme; + } + + @Nullable + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + if (Objects.equals(getDefaultScheme(), targetUri.getScheme())) { + var parsedTargetUri = ParsedDnsTargetUri.parse(targetUri, args.getDefaultPort()); + + return new K8sDnsNameResolver( + parsedTargetUri, + this.refreshIntervalSeconds, + args.getSynchronizationContext(), + args.getScheduledExecutorService()); + } else { + return null; + } + } + + private static int validateRefreshInterval(int refreshIntervalSeconds) { + checkArgument( + refreshIntervalSeconds > 0, + "refreshIntervalSeconds must be > 0, got %s", + refreshIntervalSeconds); + return refreshIntervalSeconds; + } + + private static int validatePriority(int priority) { + checkArgument( + priority >= 0 && priority <= 10, + "resolver provider priority must be [0, 10], got %s", + priority); + return priority; + } +} diff --git a/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/ParsedDnsTargetUri.java b/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/ParsedDnsTargetUri.java new file mode 100644 index 0000000..e9f707a --- /dev/null +++ b/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/ParsedDnsTargetUri.java @@ -0,0 +1,48 @@ +package com.evolution.jgrpc.tools.k8sdns; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; + +import com.google.common.base.Strings; +import java.net.URI; +import org.xbill.DNS.Name; +import org.xbill.DNS.TextParseException; + +/* package */ record ParsedDnsTargetUri(String authority, Name host, String hostStr, int port) { + + /* package */ static ParsedDnsTargetUri parse(URI targetUri, int defaultPort) { + try { + var nameUri = targetUri; + + if (Strings.isNullOrEmpty(targetUri.getAuthority())) { + // handle scheme:/// case + var targetPath = requireNonNull(targetUri.getPath(), "missing path component"); + checkArgument( + targetPath.startsWith("/"), "path component '%s' must start with '/'", targetPath); + + var name = targetPath.substring(1); + nameUri = URI.create("//" + name); + } + + var authority = requireNonNull(nameUri.getAuthority(), "missing authority"); + var hostStr = requireNonNull(nameUri.getHost(), "missing host"); + var host = parseHost(hostStr); + var port = nameUri.getPort() == -1 ? defaultPort : nameUri.getPort(); + + return new ParsedDnsTargetUri(authority, host, hostStr, port); + } catch (RuntimeException e) { + throw new IllegalArgumentException( + format("invalid DNS target URI '%s': %s", targetUri, e.getMessage()), e); + } + } + + private static Name parseHost(String hostStr) { + try { + return Name.fromString(hostStr); + } catch (TextParseException e) { + throw new IllegalArgumentException( + format("invalid host '%s': %s", hostStr, e.getMessage()), e); + } + } +} diff --git a/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/package-info.java b/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/package-info.java new file mode 100644 index 0000000..53f5158 --- /dev/null +++ b/k8s-dns-name-resolver/src/main/java/com/evolution/jgrpc/tools/k8sdns/package-info.java @@ -0,0 +1,8 @@ +/** + * @see com.evolution.jgrpc.tools.k8sdns.K8sDnsNameResolverProvider + */ +@org.jspecify.annotations.NullMarked +package com.evolution.jgrpc.tools.k8sdns; + +// TODO: #2 add integration test for K8sDnsNameResolver +// TODO: #1 add README diff --git a/k8s-dns-name-resolver/src/main/resources/META-INF/services/io.grpc.NameResolverProvider b/k8s-dns-name-resolver/src/main/resources/META-INF/services/io.grpc.NameResolverProvider new file mode 100644 index 0000000..e2192bb --- /dev/null +++ b/k8s-dns-name-resolver/src/main/resources/META-INF/services/io.grpc.NameResolverProvider @@ -0,0 +1 @@ +com.evolution.jgrpc.tools.k8sdns.K8sDnsNameResolverProvider diff --git a/k8s-dns-name-resolver/src/test/java/com/evolution/jgrpc/tools/k8sdns/ParsedDnsTargetUriTests.java b/k8s-dns-name-resolver/src/test/java/com/evolution/jgrpc/tools/k8sdns/ParsedDnsTargetUriTests.java new file mode 100644 index 0000000..3a1df19 --- /dev/null +++ b/k8s-dns-name-resolver/src/test/java/com/evolution/jgrpc/tools/k8sdns/ParsedDnsTargetUriTests.java @@ -0,0 +1,53 @@ +package com.evolution.jgrpc.tools.k8sdns; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; +import org.junit.jupiter.api.Test; +import org.xbill.DNS.Name; +import org.xbill.DNS.TextParseException; + +class ParsedDnsTargetUriTests { + + @Test + void parseCorrect() throws TextParseException { + var expected = + new ParsedDnsTargetUri( + "foo.googleapis.com", Name.fromString("foo.googleapis.com"), "foo.googleapis.com", 42); + var actual = ParsedDnsTargetUri.parse(URI.create("dns://foo.googleapis.com"), 42); + assertEquals(expected, actual); + } + + @Test + void parseCorrectWithPort() throws TextParseException { + var expected = + new ParsedDnsTargetUri( + "foo.googleapis.com:8080", + Name.fromString("foo.googleapis.com"), + "foo.googleapis.com", + 8080); + var actual = ParsedDnsTargetUri.parse(URI.create("dns://foo.googleapis.com:8080"), 42); + assertEquals(expected, actual); + } + + @Test + void parseCorrectWithExtraSlash() throws TextParseException { + var expected = + new ParsedDnsTargetUri( + "foo.googleapis.com", Name.fromString("foo.googleapis.com"), "foo.googleapis.com", 42); + var actual = ParsedDnsTargetUri.parse(URI.create("dns:///foo.googleapis.com"), 42); + assertEquals(expected, actual); + } + + @Test + void parseCorrectWithExtraSlashAndPort() throws TextParseException { + var expected = + new ParsedDnsTargetUri( + "foo.googleapis.com:8080", + Name.fromString("foo.googleapis.com"), + "foo.googleapis.com", + 8080); + var actual = ParsedDnsTargetUri.parse(URI.create("dns:///foo.googleapis.com:8080"), 42); + assertEquals(expected, actual); + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala new file mode 100644 index 0000000..89361e0 --- /dev/null +++ b/project/Dependencies.scala @@ -0,0 +1,20 @@ +import sbt.* + +object Dependencies { + val dnsJava = "dnsjava" % "dnsjava" % "3.6.3" + val jspecify = "org.jspecify" % "jspecify" % "1.0.0" + + object Grpc { + // let's keep it in sync with the version used by the last release of scalapb + private val version = "1.62.2" + + val api = "io.grpc" % "grpc-api" % version + } + + object Slf4j { + private val version = "2.0.17" + + val api = "org.slf4j" % "slf4j-api" % version + val simple = "org.slf4j" % "slf4j-simple" % version + } +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..a360cca --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version = 1.11.7 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..6149d9e --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,6 @@ +addSbtPlugin("com.github.sbt" % "sbt-java-formatter" % "0.10.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.5") +addSbtPlugin("ch.epfl.scala" % "sbt-version-policy" % "3.2.1") +addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.2") +// to be able to run JUnit 5+ tests: +addSbtPlugin("com.github.sbt.junit" % "sbt-jupiter-interface" % "0.17.0")