Skip to content

Commit

Permalink
Add support for docker secrets (#509)
Browse files Browse the repository at this point in the history
* Add simple docker compose service test

* refactor

* Parse secret and replace neo4j auth variable

* Secrets override conf variables

* Refactor

* Refactor

* Remove unused imports

* Add secrets support for 4.4

* Apply script PR corrections

* Fix TC tests

* Move resource files to separate folder

* Use waiting strategy

* Fix 4.4 suffix check

* Add logger

* Add missing secret file test

* Query config directly from database

* Fix assertion to work for 5.x and 4.x

* Test for secret file not being readable by docker

* Refactor

* Ignore test on ARM

* Ignore all docker compose tests on ARM
  • Loading branch information
stefgia authored Sep 9, 2024
1 parent 228f02f commit fc006f6
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 4 deletions.
33 changes: 33 additions & 0 deletions docker-image-src/4.4/coredb/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,33 @@ if running_as_root; then
find "${NEO4J_HOME}"/conf -type f -exec chmod -R 600 {} \;
fi

## == EXTRACT SECRETS FROM FILES ===
# These environment variables are set by using docker secrets and they override their equivalent env vars
# They are suffixed with _FILE and prefixed by the name of the env var they should override
# e.g. NEO4J_AUTH_FILE will override the value of the NEO4J_AUTH
# It's best to do this first so that the secrets are available for the rest of the script
for variable_name in $(printenv | awk -F= '{print $1}'); do
# Check if the variable ends with "_FILE"
if [[ $variable_name == *"_FILE" ]]; then
# Create a new variable name by removing the "_FILE" suffix
base_variable_name=${variable_name%_FILE}

# Get the value of the _FILE variable
secret_file_path="${!variable_name}"

if is_readable "${secret_file_path}"; then
# Read the secret value from the file
secret_value=$(<"$secret_file_path")
else
# File not readable
echo >&2 "The secret file '$secret_file_path' does not exist or is not readable. Make sure you have correctly configured docker secrets."
exit 1
fi
# Assign the value to the new variable
eval "$base_variable_name=$secret_value"
fi
done

# ==== CHECK LICENSE AGREEMENT ====

# Only prompt for license agreement if command contains "neo4j" in it
Expand Down Expand Up @@ -553,6 +580,12 @@ for i in $( set | grep ^NEO4J_ | awk -F'=' '{print $1}' | sort -rn ); do
if containsElement "$i" "${not_configs[@]}"; then
continue
fi

# Skip env variables with suffix _FILE, these are docker secrets
if [[ "$i" == *"_FILE" ]]; then
continue
fi

setting=$(echo "${i}" | sed 's|^NEO4J_||' | sed 's|_|.|g' | sed 's|\.\.|_|g')
value=$(echo "${!i}")
# Don't allow settings with no value or settings that start with a number (neo4j converts settings to env variables and you cannot have an env variable that starts with a number)
Expand Down
33 changes: 33 additions & 0 deletions docker-image-src/5/coredb/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,33 @@ if running_as_root; then
find "${NEO4J_HOME}"/conf -type f -exec chmod -R 600 {} \;
fi

## == EXTRACT SECRETS FROM FILES ===
# These environment variables are set by using docker secrets and they override their equivalent env vars
# They are suffixed with _FILE and prefixed by the name of the env var they should override
# e.g. NEO4J_AUTH_FILE will override the value of the NEO4J_AUTH
# It's best to do this first so that the secrets are available for the rest of the script
for variable_name in $(printenv | awk -F= '{print $1}'); do
# Check if the variable ends with "_FILE"
if [[ $variable_name == *"_FILE" ]]; then
# Create a new variable name by removing the "_FILE" suffix
base_variable_name=${variable_name%_FILE}

# Get the value of the _FILE variable
secret_file_path="${!variable_name}"

if is_readable "${secret_file_path}"; then
# Read the secret value from the file
secret_value=$(<"$secret_file_path")
else
# File not readable
echo >&2 "The secret file '$secret_file_path' does not exist or is not readable. Make sure you have correctly configured docker secrets."
exit 1
fi
# Assign the value to the new variable
eval "$base_variable_name=$secret_value"
fi
done

# ==== CHECK LICENSE AGREEMENT ====

# Only prompt for license agreement if command contains "neo4j" in it
Expand Down Expand Up @@ -567,6 +594,12 @@ for i in $( set | grep ^NEO4J_ | awk -F'=' '{print $1}' | sort -rn ); do
if containsElement "$i" "${not_configs[@]}"; then
continue
fi

# Skip env variables with suffix _FILE, these are docker secrets
if [[ "$i" == *"_FILE" ]]; then
continue
fi

setting=$(echo "${i}" | sed 's|^NEO4J_||' | sed 's|_|.|g' | sed 's|\.\.|_|g')
value=$(echo "${!i}")
# Don't allow settings with no value or settings that start with a number (neo4j converts settings to env variables and you cannot have an env variable that starts with a number)
Expand Down
173 changes: 173 additions & 0 deletions src/test/java/com/neo4j/docker/TestDockerComposeSecrets.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package com.neo4j.docker;

import com.neo4j.docker.coredb.configurations.Configuration;
import com.neo4j.docker.coredb.configurations.Setting;
import com.neo4j.docker.utils.DatabaseIO;
import com.neo4j.docker.utils.TemporaryFolderManager;
import com.neo4j.docker.utils.TestSettings;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.DockerComposeContainer;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.containers.output.ToStringConsumer;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Duration;

import static com.neo4j.docker.utils.WaitStrategies.waitForBoltReady;

public class TestDockerComposeSecrets
{
private static final Logger log = LoggerFactory.getLogger( TestDockerComposeSecrets.class );

private static final int DEFAULT_BOLT_PORT = 7687;
private static final int DEFAULT_HTTP_PORT = 7474;
private static final Path TEST_RESOURCES_PATH = Paths.get( "src", "test", "resources", "dockersecrets" );

@RegisterExtension
public static TemporaryFolderManager temporaryFolderManager = new TemporaryFolderManager();

@BeforeAll
public static void skipTestsForARM()
{
Assumptions.assumeFalse( System.getProperty( "os.arch" ).equals( "aarch64" ),
"This test is ignored on ARM architecture, because Docker Compose Container doesn't support it." );
}

private DockerComposeContainer createContainer( File composeFile, Path containerRootDir, String serviceName )
{
var container = new DockerComposeContainer( composeFile );

container.withExposedService( serviceName, DEFAULT_BOLT_PORT )
.withExposedService( serviceName, DEFAULT_HTTP_PORT )
.withEnv( "NEO4J_IMAGE", TestSettings.IMAGE_ID.asCanonicalNameString() )
.withEnv( "HOST_ROOT", containerRootDir.toAbsolutePath().toString() )
.waitingFor( serviceName, waitForBoltReady( Duration.ofSeconds( 90 ) ) )
.withLogConsumer( serviceName, new Slf4jLogConsumer( log ) );

return container;
}

@Test
void shouldCreateContainerAndConnect() throws Exception
{
var tmpDir = temporaryFolderManager.createFolder( "Simple_Container_Compose" );
var composeFile = copyDockerComposeResourceFile( tmpDir, TEST_RESOURCES_PATH.resolve( "simple-container-compose.yml" ).toFile() );
var serviceName = "simplecontainer";

try ( var dockerComposeContainer = createContainer( composeFile, tmpDir, serviceName ) )
{
dockerComposeContainer.start();

var dbio = new DatabaseIO( dockerComposeContainer.getServiceHost( serviceName, DEFAULT_BOLT_PORT ),
dockerComposeContainer.getServicePort( serviceName, DEFAULT_BOLT_PORT ) );
dbio.verifyConnectivity( "neo4j", "simplecontainerpassword" );
}
}

@Test
void shouldCreateContainerWithSecretPasswordAndConnect() throws Exception
{
var tmpDir = temporaryFolderManager.createFolder( "Container_Compose_With_Secrets" );
var composeFile = copyDockerComposeResourceFile( tmpDir, TEST_RESOURCES_PATH.resolve( "container-compose-with-secrets.yml" ).toFile() );
var serviceName = "secretscontainer";

var newSecretPassword = "neo4j/newSecretPassword";
Files.createFile( tmpDir.resolve( "neo4j_auth.txt" ) );
Files.writeString( tmpDir.resolve( "neo4j_auth.txt" ), newSecretPassword );

try ( var dockerComposeContainer = createContainer( composeFile, tmpDir, serviceName ) )
{
dockerComposeContainer.start();
var dbio = new DatabaseIO( dockerComposeContainer.getServiceHost( serviceName, DEFAULT_BOLT_PORT ),
dockerComposeContainer.getServicePort( serviceName, DEFAULT_BOLT_PORT ) );
dbio.verifyConnectivity( "neo4j", "newSecretPassword" );
}
}

@Test
void shouldOverrideVariableWithSecretValue() throws Exception
{
var tmpDir = temporaryFolderManager.createFolder( "Container_Compose_With_Secrets_Override" );
Files.createDirectories( tmpDir.resolve( "neo4j" ).resolve( "config" ) );

var composeFile = copyDockerComposeResourceFile( tmpDir, TEST_RESOURCES_PATH.resolve( "container-compose-with-secrets-override.yml" ).toFile() );
var serviceName = "secretsoverridecontainer";

var newSecretPageCache = "50M";
Files.createFile( tmpDir.resolve( "neo4j_pagecache.txt" ) );
Files.writeString( tmpDir.resolve( "neo4j_pagecache.txt" ), newSecretPageCache );

try ( var dockerComposeContainer = createContainer( composeFile, tmpDir, serviceName ) )
{
dockerComposeContainer.start();

var dbio = new DatabaseIO( dockerComposeContainer.getServiceHost( serviceName, DEFAULT_BOLT_PORT ),
dockerComposeContainer.getServicePort( serviceName, DEFAULT_BOLT_PORT ) );

var secretSetting = dbio.getConfigurationSettingAsString( "neo4j",
"secretsoverridecontainerpassword",
Configuration.getConfigurationNameMap().get( Setting.MEMORY_PAGECACHE_SIZE ) );

Assertions.assertTrue( secretSetting.contains( "50" ) );
}
}

@Test
void shouldFailIfSecretFileDoesNotExist() throws Exception
{
var tmpDir = temporaryFolderManager.createFolder( "Container_Compose_With_Secrets_Override" );
var composeFile = copyDockerComposeResourceFile( tmpDir, TEST_RESOURCES_PATH.resolve( "container-compose-with-secrets-override.yml" ).toFile() );
var serviceName = "secretsoverridecontainer";

try ( var dockerComposeContainer = createContainer( composeFile, tmpDir, serviceName ) )
{
Assertions.assertThrows( Exception.class, dockerComposeContainer::start );
}
}

@Test
void shouldFailAndPrintMessageIfFileIsNotReadable() throws Exception
{
var tmpDir = temporaryFolderManager.createFolder( "Container_Compose_With_Secrets_Override" );
var composeFile = copyDockerComposeResourceFile( tmpDir, TEST_RESOURCES_PATH.resolve( "container-compose-with-secrets-override.yml" ).toFile() );
var serviceName = "secretsoverridecontainer";

Files.createFile( tmpDir.resolve( "neo4j_pagecache.txt" ) );
Files.writeString( tmpDir.resolve( "neo4j_pagecache.txt" ), "50M" );

var newPermissions = PosixFilePermissions.fromString( "rw-------" );
Files.setPosixFilePermissions( tmpDir.resolve( "neo4j_pagecache.txt" ), newPermissions );

try ( var dockerComposeContainer = createContainer( composeFile, tmpDir, serviceName ) )
{
var containerLogConsumer = new ToStringConsumer();
dockerComposeContainer.withLogConsumer( serviceName, containerLogConsumer );
var expectedLogLine = "The secret file '/run/secrets/neo4j_dbms_memory_pagecache_size_file' does not exist or is not readable. " +
"Make sure you have correctly configured docker secrets.";
Assertions.assertThrows( Exception.class, dockerComposeContainer::start );
Assertions.assertTrue( containerLogConsumer.toUtf8String().contains( expectedLogLine ) );
}
}

private File copyDockerComposeResourceFile( Path targetDirectory, File resourceFile ) throws IOException
{
File compose_file = new File( targetDirectory.toString(), resourceFile.getName() );
if ( compose_file.exists() )
{
Files.delete( compose_file.toPath() );
}
Files.copy( resourceFile.toPath(), Paths.get( compose_file.getPath() ) );
return compose_file;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.output.OutputFrame;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.containers.output.WaitingConsumer;
import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy;
import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy;
import org.testcontainers.containers.wait.strategy.Wait;
Expand All @@ -28,13 +27,10 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;

import org.neo4j.driver.exceptions.ClientException;
Expand Down
4 changes: 4 additions & 0 deletions src/test/java/com/neo4j/docker/utils/DatabaseIO.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public DatabaseIO( GenericContainer container )
this.boltUri = "bolt://"+container.getHost()+":"+container.getMappedPort( 7687 );
}

public DatabaseIO( String host, Integer boltPort )
{
this.boltUri = "bolt://" + host + ":" + boltPort;
}

public void putInitialDataIntoContainer( String user, String password )
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
services:
secretsoverridecontainer:
image: ${NEO4J_IMAGE}
environment:
- NEO4J_ACCEPT_LICENSE_AGREEMENT=yes
- NEO4J_dbms_memory_pagecache_size_FILE=/run/secrets/neo4j_dbms_memory_pagecache_size_file
- NEO4J_dbms_memory_pagecache_size=10M
- NEO4J_DEBUG=true
- NEO4J_AUTH=neo4j/secretsoverridecontainerpassword
volumes:
- ${HOST_ROOT}/neo4j/logs:/logs
secrets:
- neo4j_dbms_memory_pagecache_size_file
secrets:
neo4j_dbms_memory_pagecache_size_file:
file: ./neo4j_pagecache.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
services:
secretscontainer:
image: ${NEO4J_IMAGE}
environment:
- NEO4J_ACCEPT_LICENSE_AGREEMENT=yes
- NEO4J_AUTH_FILE=/run/secrets/neo4j_auth_file
- NEO4J_DEBUG=true
volumes:
- ${HOST_ROOT}/neo4j/data:/data
- ${HOST_ROOT}/neo4j/logs:/logs
secrets:
- neo4j_auth_file
secrets:
neo4j_auth_file:
file: ./neo4j_auth.txt
10 changes: 10 additions & 0 deletions src/test/resources/dockersecrets/simple-container-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
services:
simplecontainer:
image: ${NEO4J_IMAGE}
environment:
- NEO4J_ACCEPT_LICENSE_AGREEMENT=yes
- NEO4J_AUTH=neo4j/simplecontainerpassword
- NEO4J_DEBUG=true
volumes:
- ${HOST_ROOT}/neo4j/data:/data
- ${HOST_ROOT}/neo4j/logs:/logs

0 comments on commit fc006f6

Please sign in to comment.