jetcd icon indicating copy to clipboard operation
jetcd copied to clipboard

io.grpc.StatusRuntimeException: INVALID_ARGUMENT: etcdserver: revision of auth store is old - client doesn't retry.

Open vivekpatani opened this issue 1 year ago • 9 comments

Versions

  • etcd: 3.5.9
  • jetcd: main
  • java: openjdk 20.0.2 2023-07-18

Describe the bug When spinning up etcd with --auth-token=jwt,pub-key=jwt_RS256.pub,priv-key=jwt_RS256,sign-method=RS256 --auth-token-ttl=1, run into this io.grpc.StatusRuntimeException: INVALID_ARGUMENT: etcdserver: revision of auth store is old and the client fails to retry.

To Reproduce

  • Create a tmp directory
  • cd tmp
  • Create a Dockerfile
FROM gcr.io/etcd-development/etcd:v3.5.9

COPY jwt_RS256 /usr/local/bin/jwt_RS256
COPY jwt_RS256 /var/etcd/jwt_RS256
COPY jwt_RS256 /var/lib/etcd/jwt_RS256
COPY jwt_RS256 /jwt_RS256

COPY jwt_RS256.pub /usr/local/bin/jwt_RS256.pub
COPY jwt_RS256.pub /var/etcd/jwt_RS256.pub
COPY jwt_RS256.pub /var/lib/etcd/jwt_RS256.pub
COPY jwt_RS256.pub /jwt_RS256.pub

ENV ETCD_UNSUPPORTED_ARCH=arm64

EXPOSE 2379 2380

# Define default command.
CMD ["/usr/local/bin/etcd"]
  • Create random keys
openssl genrsa -out jwt_RS256 4096
openssl rsa -in jwt_RS256 -pubout > jwt_RS256.pub
  • docker build -t etcd-custom .
  • Open jetcd project, and create AuthClientTTLTest.java
package io.etcd.jetcd.impl;

import io.etcd.jetcd.Auth;
import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.KV;
import io.etcd.jetcd.auth.AuthRoleListResponse;
import io.etcd.jetcd.auth.Permission;
import io.etcd.jetcd.kv.DeleteResponse;
import io.etcd.jetcd.kv.GetResponse;
import io.etcd.jetcd.test.EtcdClusterExtension;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.extension.RegisterExtension;

import java.util.List;
import java.util.concurrent.TimeUnit;

import static io.etcd.jetcd.impl.TestUtil.bytesOf;
import static org.assertj.core.api.Assertions.assertThat;

@Timeout(value = 30, unit = TimeUnit.SECONDS)
public class AuthClientTTLTest {
    @RegisterExtension
    public static final EtcdClusterExtension cluster = EtcdClusterExtension.builder()
            .withNodes(1)
            .withAdditionalArgs(List.of("--auth-token=jwt,pub-key=jwt_RS256.pub,priv-key=jwt_RS256,sign-method=RS256", "--auth-token-ttl=1"))
            .withImage("etcd-custom")
            .build();

    private static final String rootString = "root";
    private static final ByteSequence rootPass = bytesOf("123");
    private static final String rootRoleString = "root";
    private static final String userString = "user";
    private static final String userRoleString = "userRole";
    private static Auth authDisabledAuthClient;
    private static KV authDisabledKVClient;
    private final ByteSequence rootRoleKey = bytesOf("root");
    private final ByteSequence rootRoleValue = bytesOf("b");
    private final ByteSequence rootRoleKeyRangeBegin = bytesOf("root");
    private final ByteSequence rootRoleKeyRangeEnd = bytesOf("root1");
    private final ByteSequence userRoleKey = bytesOf("foo");
    private final ByteSequence userRoleValue = bytesOf("bar");
    private final ByteSequence userRoleKeyRangeBegin = bytesOf("foo");
    private final ByteSequence userRoleKeyRangeEnd = bytesOf("foo1");
    private final ByteSequence root = bytesOf(rootString);
    private final ByteSequence rootRole = bytesOf(rootRoleString);
    private final ByteSequence user = bytesOf(userString);
    private final ByteSequence userPass = bytesOf("userPass");
    private final ByteSequence userNewPass = bytesOf("newUserPass");
    private final ByteSequence userRole = bytesOf(userRoleString);
    private final ByteSequence testKey = bytesOf("test_key");
    private final ByteSequence testValue = bytesOf("test_value");

    /**
     * Build etcd client to create role, permission.
     */
    @BeforeAll
    public static void setupEnv() {
        Client client = TestUtil.client(cluster).build();
        authDisabledAuthClient = client.getAuthClient();
        authDisabledKVClient = client.getKVClient();
    }

    @Test
    public void testAuth() throws Exception {
        // add root auth role, list auth role, and verify
        authDisabledAuthClient.roleAdd(rootRole).get();
        final AuthRoleListResponse response = authDisabledAuthClient.roleList().get();
        assertThat(response.getRoles()).containsOnly(rootRoleString);

        // role grant permission
        authDisabledAuthClient
                .roleGrantPermission(rootRole, rootRoleKeyRangeBegin, rootRoleKeyRangeEnd, Permission.Type.READWRITE).get();


        // add root user, list users and verify
        authDisabledAuthClient.userAdd(root, rootPass).get();
        List<String> users = authDisabledAuthClient.userList().get().getUsers();
        assertThat(users).containsOnly(rootString);

        // role granted to user and verify
        authDisabledAuthClient.userGrantRole(root, rootRole).get();
        assertThat(authDisabledAuthClient.userGet(root).get().getRoles()).containsOnly(rootRoleString);

        // enable auth
        authDisabledAuthClient.authEnable().get();

        // create an auth enabled root client
        final Client authEnabledRootClient = TestUtil.client(cluster).user(root).password(rootPass).build();
        final Auth authEnabledAuthClient = authEnabledRootClient.getAuthClient();
        final KV authEnabledKVClient = authEnabledRootClient.getKVClient();

        authEnabledKVClient.put(testKey, testValue).get();
        final GetResponse getResponse = authEnabledKVClient.get(testKey).get();

        authEnabledAuthClient.roleAdd(userRole).get();
        authEnabledAuthClient.roleGrantPermission(userRole, userRoleKeyRangeBegin, userRoleKeyRangeEnd, Permission.Type.READWRITE).get();
        authEnabledAuthClient.userAdd(user, userPass).get();
        authEnabledAuthClient.userGrantRole(user, userRole);

        Thread.sleep(5000);

        DeleteResponse deleteResponse = authEnabledKVClient.delete(testKey).get();
    }
}

Expected behavior Client should retry.

Additional context I see there was a fix for this: https://github.com/etcd-io/jetcd/pull/1103, but maybe I'm missing context here.

If it's easier to recreate this issue, I've made a standalone app so that you can hit the etcd-server

/*
 * This Java source file was generated by the Gradle 'init' task.
 */
package jetcd.sample;

import io.etcd.jetcd.Auth;
import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.KV;
import io.etcd.jetcd.auth.Permission;
import io.etcd.jetcd.kv.GetResponse;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class App {
    public String getGreeting() {
        return "Hello World!";
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        System.out.println(new App().getGreeting());
        final ByteSequence user = ByteSequence.from("root".getBytes());
        final ByteSequence pass = ByteSequence.from("root".getBytes());

        // create client using endpoints
        final  Client client1 = Client.builder()
                .user(user)
                .password(user)
                .endpoints("http://localhost:2379")
                .build();

        // everything works fine
        final KV kvClient = client1.getKVClient();
        final ByteSequence key = ByteSequence.from("test_key".getBytes());
        final ByteSequence value = ByteSequence.from("test_value".getBytes());

        // put the key-value
        kvClient.put(key, value).get();

        // get the CompletableFuture
        CompletableFuture<GetResponse> getFuture = kvClient.get(key);

        // get the value from CompletableFuture
        GetResponse response = getFuture.get();

        System.out.println(response.toString());

        // create client using endpoints
        final  Client client2 = Client.builder()
                .user(user)
                .password(user)
                .endpoints("http://localhost:2379")
                .build();

        final Auth authClient = client2.getAuthClient();
        final ByteSequence role0 = ByteSequence.from("role0".getBytes());
        final ByteSequence user0 = ByteSequence.from("user0".getBytes());
        authClient.roleAdd(role0);
        authClient.roleGrantPermission(role0, key, key, Permission.Type.READWRITE);
        authClient.userAdd(user0, pass);
        authClient.userGrantRole(user0, role0);

        Thread.sleep(11000);

        // should fail
        kvClient.delete(key).get();

        // close
        client1.close();

        System.out.println(new App().getGreeting());
    }
}

vivekpatani avatar Aug 08 '23 02:08 vivekpatani

can you submit a PR with the failing test ? I know there is some code here but a PR would eventually speed up the implementation of a fix

lburgazzoli avatar Aug 08 '23 05:08 lburgazzoli

@lburgazzoli i can submit a PR but the test needs a custom image, which is why i had to submit it like this.

Currently the test setup doesn't allow for certificates, it takes the etcd image from upstream and uses that, but in order to reproduce the problem you need to pass a custom etcd with certificates already present which is why I've jotted the steps down. Not sure if it makes sense. Thanks.

vivekpatani avatar Aug 08 '23 13:08 vivekpatani

we have some tests that add the certificates and some that use jwt too, like https://github.com/etcd-io/jetcd/blob/b95fc68fb319dac2c5baaa231b350a56b12764c9/jetcd-core/src/test/java/io/etcd/jetcd/impl/WatchTokenExpireTest.java#L52-L59

lburgazzoli avatar Aug 08 '23 13:08 lburgazzoli

That makes more sense, let me fix the test and submit a PR in sometime. Thanks.

vivekpatani avatar Aug 08 '23 13:08 vivekpatani