Skip to content

Commit

Permalink
fix(cli): Use an IPV6-enabled network in CLI's compose file (#1299)
Browse files Browse the repository at this point in the history
This PR changes two things:

1. Removes one of the compose files, reducing duplication. Now we can no
longer get into a situation where the sync service definition is updated
in one file but not in the other one.
2. Adds an IPv6-enabled network definition to the compose file and makes
the electric service use that network.

### Background 

Beyond requiring support in the OS, the network router and the ISP,
there are two more problems with using IPv6 for database connections:

* it's not automatically enabled in Docker
* an explicit network configuration needs to be added to the compose
file when the sync service is started by `docker compose`

The first problem is relevant to those users who utilize `docker run` to
start the sync service. The second problem currently makes our CLI
unusable with IPv6 because the compose file bundled with it has no
provisions for IPv6 networking. This PR address the latter.

### Testing

I have tested connectivity from the sync service to a Supabase database
over IPv6 and to a DigitalOcean Managed PostgreSQL over IPv4. Here's
what I've learned:

* Once IPv6 support has been enabled in the Docker daemon, it seems to
persist even after the configuration file is removed and the daemon is
restarted. For example, with IPv6 disabled in the Docker daemon (such
that `docker run ... curlimages/curl -6 https://ifconfig.co/ip` fails to
connect to the remote host) it is still possible to define an
IPv6-enabled network in a compose file and the services started by
`docker compose` will be able to connect to remote hosts over IPv6.
* The compose file may include a minimalistic network definition that
only includes the `enabled_ipv6: true` line. But for this to work, the
Docker daemon must be configured with an IPv6 default pool. Otherwise,
the network definition in the compose file has to include a subnet
definition.
* Alternatively, the network definition in the compose file may be
marked with `external: true` to instruct `docker compose` to use a
user-created Docker network instead of creating a new one.
* The CIDR prefix configured in Docker daemon's default pool(s) and the
subnet in the compose file do not need to match.
* An IPv6-enabled network also supports IPv4 connectivity.

In summary, including a default IPv6-enabled network definition in our
packaged compose file should work regardless of how users have their
Docker daemon configured.

There is a potential for conflict, though. If there's already a Docker
network on the machine that has an overlapping subnet with the one we
define in our compose file, `docker compose up` will fail. To minimize
the chances of conflicts, I have chosen a subnet with randomly generated
groups that has the size of only 4 address. To make it fully
conflict-proof, the compose file uses an environment variable that
allows for switching the host network mode or using an external,
user-created Docker network by name.

Fixes #863.
  • Loading branch information
alco committed Jul 1, 2024
1 parent 9c16bb5 commit e518919
Show file tree
Hide file tree
Showing 9 changed files with 145 additions and 80 deletions.
5 changes: 5 additions & 0 deletions .changeset/dry-gifts-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"electric-sql": patch
---

Fix the Docker Compose file that's bundled with the client CLI to support databases that are only reachable over IPv6, such as Supabase.
9 changes: 8 additions & 1 deletion components/cli/src/config-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,14 @@ export const configOptions: Record<string, any> = {
valueType: String,
valueTypeName: 'features',
defaultVal: '',
doc: 'Flags to enable experimental features',
doc: 'Flags to enable experimental features.',
groups: ['electric'],
},
DOCKER_NETWORK_USE_EXTERNAL: {
valueType: String,
valueTypeName: "'host' | name",
defaultVal: '',
doc: "Name of an existing Docker network to use or 'host' to run the container using the host OS networking.",
groups: ['electric'],
},
} as const
7 changes: 3 additions & 4 deletions components/cli/src/docker-commands/command-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
Config,
printConfig,
} from '../config'
import { dockerCompose } from './docker-utils'
import { dockerCompose, dockerComposeUp } from './docker-utils'

export function makeStartCommand() {
const command = new Command('start')
Expand Down Expand Up @@ -88,9 +88,8 @@ export function start(options: StartSettings) {
console.log('Docker compose config:')
printConfig(dockerConfig)

const proc = dockerCompose(
'up',
[...(options.detach ? ['--detach'] : [])],
const proc = dockerComposeUp(
options.detach ? ['--detach'] : [],
options.config.CONTAINER_NAME,
dockerConfig
)
Expand Down
58 changes: 46 additions & 12 deletions components/cli/src/docker-commands/docker-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,74 @@ import { spawn } from 'child_process'
import path from 'path'
import { fileURLToPath } from 'url'

const composeFile = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'docker',
'compose.yaml'
)
function composeFilePath(filename: string) {
return path.join(
path.dirname(fileURLToPath(import.meta.url)),
'docker',
filename
)
}

function useExternalDockerNetwork(networkName?: string): boolean {
return !!(networkName && networkName.length > 0)
}

const composeFileWithPostgres = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'docker',
'compose-with-postgres.yaml'
)
// Derive network name from the current working directory, matching Docker Compose's default
// naming.
function deriveNetworkName(networkName?: string): string {
if (networkName && networkName.length > 0) return networkName
return path.basename(process.cwd()) + '_ip6net'
}

export function dockerCompose(
command: string,
userArgs: string[] = [],
containerName?: string,
env: { [key: string]: string } = {}
) {
const withPostgres = env?.COMPOSE_PROFILES === 'with-postgres'
const composeFile = 'compose.yaml'
const extraComposeFile =
env.DOCKER_NETWORK_USE_EXTERNAL === 'host'
? 'compose.hostnet.yaml'
: 'compose.ip6net.yaml'
const args = [
'compose',
'--ansi',
'always',
'-f',
withPostgres ? composeFileWithPostgres : composeFile,
composeFilePath(composeFile),
'-f',
composeFilePath(extraComposeFile),
command,
...userArgs,
]
return spawn('docker', args, {
stdio: 'inherit',
env: {
ELECTRIC_COMPOSE_NETWORK_IS_EXTERNAL: useExternalDockerNetwork(
env.DOCKER_NETWORK_USE_EXTERNAL
).toString(),
ELECTRIC_COMPOSE_EXTERNAL_NETWORK_NAME: deriveNetworkName(
env.DOCKER_NETWORK_USE_EXTERNAL
),
...process.env,
...(containerName ? { COMPOSE_PROJECT_NAME: containerName } : {}),
...env,
},
})
}

export function dockerComposeUp(
userArgs: string[] = [],
containerName?: string,
env: { [key: string]: string } = {}
) {
// We use the same compose.yaml file for `electric-sql start` and `electric-sql start
// --with-postgres` and vary the services started by passing them as arguments to `docker
// compose up`.
const services =
env.COMPOSE_PROFILES === 'with-postgres'
? ['postgres', 'electric']
: ['electric']
return dockerCompose('up', userArgs.concat(services), containerName, env)
}

This file was deleted.

13 changes: 13 additions & 0 deletions components/cli/src/docker-commands/docker/compose.hostnet.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# This extra compose file is meant to be used together with the main compose.yaml file when
# running Docker Compose commands.
#
# In this file we configure both services to use the host network mode, side-stepping Docker
# networking entirely.

services:
electric:
network_mode: host

postgres:
# Postgres must be on the same network as the sync service.
network_mode: host
55 changes: 55 additions & 0 deletions components/cli/src/docker-commands/docker/compose.ip6net.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# This extra compose file is meant to be used together with the main compose.yaml file when
# running Docker Compose commands.
#
# In this file we define an IPv6-enabled network and assign it to both "electric" and
# "postgres" services.

networks:
# An IPv6-enabled network is necessary for the sync service to connect to
# databases that are only reachable at IPv6 addresses. IPv4 can also be used as usual with
# this network.
ip6net:
enable_ipv6: true
# These two options provide an escape hatch that allows users to define their own Docker
# network using `docker network create` and use that for the services defined in this
# compose file.
#
# These environment variables are set by the CLI and are derived from the single
# user-facing `ELECTRIC_DOCKER_NETWORK_USE_EXTERNAL` variable.
external: ${ELECTRIC_COMPOSE_NETWORK_IS_EXTERNAL}
name: '${ELECTRIC_COMPOSE_EXTERNAL_NETWORK_NAME}'
ipam:
config:
# Subnet definition isn't required if the Docker daemon has a default address pool for
# IPv6 addresses configured. However, since that's not the case for Docker
# out-of-the-box (at least until version 27.0.1) and since we want to free our users
# from additional manual configuration when possible, we include a default subnet
# definition here that should work with any Docker daemon configuration.
#
# The fd00:: prefix is part of the address space reserved for Unique Local Addresses,
# i.e. private networks. This is analogous to 192.168.x.x in the IPv4 land.
#
# There is a possibility that this subnet overlaps with another network configured
# separately for the same Docker daemon. That situation would result in `docker compose
# up` failing with the error message
#
# Error response from daemon: Pool overlaps with other one on this address space
#
# To resolve this conflict, an external Docker network can be used by setting
# `ELECTRIC_DOCKER_NETWORK_USE_EXTERNAL` to its name.
#
# The default subnet here has 2 randomly generated bytes in the 2nd and the 6th groups,
# it can accommodate 4 addresses which is small enough to further reduce any chance of conflicts.
- subnet: 'fd00:56f0::4acd:f0fc/126'

services:
electric:
networks:
# Despite the name, assigning this network to the sync service does not preclude the use
# of IPv4.
- ip6net

postgres:
networks:
# Postgres must be on the same network as the sync service.
- ip6net
14 changes: 14 additions & 0 deletions components/cli/src/docker-commands/docker/compose.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
# This compose file is used by the CLI `start` command to run the sync service and, optionally,
# a Postgres database inside Docker containers.
#
# When a user wants to start only the sync service by executing `electric-sql start`, the CLI
# will execute `docker compose up ... electric`, omitting the "postgres" service, so only the
# "electric" service will be running.
#
# When `electric-sql start --with-postgres` is invoked, the CLI will execute `docker compose up
# ... postgres electric`. In this case, the sync service may spend some time in a reconnection loop
# until the Postgres database is ready to accept connections. This is a normal mode of operation.
#
# Docker Compose should be invoked with this file and one of the two extra
# files, compose.hostnest.yaml or compose.ip6net.yaml, depending on the chosen network mode.

configs:
postgres_config:
file: './postgres.conf'
Expand Down
1 change: 1 addition & 0 deletions components/cli/test/config-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const expectedEnvVars = [
'ELECTRIC_IMAGE',
'CONTAINER_NAME',
'ELECTRIC_FEATURES',
'DOCKER_NETWORK_USE_EXTERNAL',
]

test('assert that all expected env vars are options for CLI', (t) => {
Expand Down

0 comments on commit e518919

Please sign in to comment.