Skip to content

Run the test servers in containers instead of compiling them - #73

Open
christhomas wants to merge 3 commits into
secsy:masterfrom
antimatter-studios:cth/containerised-test-servers
Open

Run the test servers in containers instead of compiling them#73
christhomas wants to merge 3 commits into
secsy:masterfrom
antimatter-studios:cth/containerised-test-servers

Conversation

@christhomas

Copy link
Copy Markdown

build_test_server.sh builds the two FTP servers the suite needs by fetching proftpd 1.3.5 and pure-ftpd 1.0.36 as tarballs over plain FTP, patching their C with perl — including a workaround for a segfault on macOS — and leaving the binaries in the working tree.

That makes the versions under test whatever the script last managed to build on your particular machine, if it built at all, so a failure cannot be attributed with confidence. It also puts the suite out of reach on any machine where you would rather not compile an FTP daemon.

This replaces it with containers: proftpd 1.3.8 from Debian and pure-ftpd 1.0.54 built in the image — twice from the same source, because implicit TLS is a compile-time option no distribution packages, which is the only reason anything is still compiled at all.

./scripts/test-servers.sh test

Docker is the only prerequisite.

The suite runs in a container too, and that is the point

FTP negotiates transfers by address. A passive transfer has the server tell the client where to connect; an active one has the server connect back. A server behind published ports names an address the host cannot route to, so passive transfers time out and active ones are refused outright:

500-I won't open a connection to 127.0.0.1 (only to 172.21.0.1)

Sharing a network makes the address the server names the address the client can reach. The ports are published anyway, so go test from the host still works for everything that is not a data transfer.

TestMain finds servers rather than starting them

It connects to whatever is listening. If nothing is, every server-dependent test skips with a reason rather than failing on a connection refused — which reads like a bug in the package instead of a missing prerequisite. GOFTP_SKIP_SERVERS forces that path, and CI uses it to prove the server-free tests really are server-free.

The consequence worth knowing: the suite no longer owns the servers' lifetime, so a test must not leave one in a state the next cannot use.

Three things the newer servers changed

  • The certificate is generated at image build time. The committed one expired in 2015; the tests skip verification, so nobody noticed — which is the argument against keeping it rather than for it.
  • pure-ftpd's anonymous root comes from FTP_ANON_DIR. The implicit-TLS test logs in anonymously, and without it that login lands in pure-ftpd's compiled-in /ftp and cannot chdir.
  • Stat no longer asserts a name for the root directory. All three servers describe the same directory and disagree about what it is called: proftpd says testroot, pure-ftpd 1.0.36 said /, 1.0.54 says .. There is no right answer to hold them to, so the name is checked against that set and the fields that do have a right answer are still compared. A directory's modification time is no longer compared either — it moves whenever the directory changes, which this suite does constantly, so comparing it raced with the suite's own writes.

The timeout

Config.Timeout for the suite is 30 seconds rather than the package default of 5. Five was ample for processes on the same machine; across a bridge network that on macOS sits inside a VM, tests failed intermittently at almost exactly five seconds — a different handful each run. A test timing out is not a test failing.

CI

.travis.yml tested Go 1.10 and tip. The workflow replacing it runs the containers and pins both matrix entries: the floor go.mod declares, and a current release. "stable" moves on its own, which makes a build green yesterday and red today with no commit in between.

Verified: full suite green on Go 1.21 and 1.25, three consecutive runs each, and the server-free subset green with no containers at all.

Independent of #71 and #72 — happy to rebase whichever lands last.

A server answering MKD with 257 has created the directory. RFC 959 asks
it to echo the pathname in quotes as well, and not every server does —
proftpd and several embedded servers answer a bare "257 Directory
created".

Mkdir treated that as a parse failure and returned an error, so the
caller saw a failure for a directory that now exists. Retrying then
reports that it already does, which is a confusing place to end up from
a call that worked.

A reply without a name is a success whose name cannot be read, not a
failure. Mkdir now answers with the path it was asked for.

Getwd is left alone deliberately. It calls the same parser, but there
the pathname *is* the answer — a PWD reply without one has nothing to
return, so failing is correct. The added parser test pins that
distinction so a later change cannot relax both at once.

Found by a filesystem driver whose test server sends exactly this reply:
every Mkdir failed while every directory was created.
Adds Config.DialFunc. When set, it opens every TCP connection the client
makes — control connections and data connections alike — in place of the
package's own dialer.

It exists for callers who need the connection itself rather than only
what travels over it: counting bytes for transfer statistics, routing
through a proxy or a tunnel, or returning something that is not really a
socket. Today there is no way to reach the connection, so those callers
cannot use this package at all.

Both dial sites now funnel through one Config.dial helper, which is what
makes the data connections reachable too. That is the part worth having:
a caller who supplied a dialer to count bytes and only saw the control
connection would miss every transfer, which is nearly all the bytes
there are, and would have no way to tell.

Two things become the caller's responsibility, documented on the field
because neither is guessable:

- Config.Timeout no longer applies to the dial. It still governs reads
  and writes on the returned connection.
- In TLSImplicit mode the returned connection is wrapped in TLS by this
  package, so DialFunc should return a plain connection. The handshake
  happens at dial time rather than on first read, matching what
  tls.DialWithDialer does.

Leaving DialFunc nil keeps the previous behaviour exactly, including the
timeout.

## Running the tests without the test servers

The new tests need no server: both call sites go through Config.dial, so
exercising that covers them by construction. But the package's TestMain
stops with log.Fatal when ./build_test_server.sh has not been run, so on
a machine that cannot build pure-ftpd and proftpd no test runs at all —
including the ones that do not care.

GOFTP_SKIP_SERVERS now skips the four tests that need a live server and
runs the rest. Skipping is on the tests themselves rather than the
runner, so they report as skipped with the reason rather than failing on
a connection refused, which reads like a bug in the package instead of a
missing prerequisite.
The suite needs two FTP servers, and got them by compiling them on
whatever machine ran the tests. build_test_server.sh fetched proftpd
1.3.5 and pure-ftpd 1.0.36 as tarballs over plain FTP, patched their C
with perl — including a workaround for a segfault on macOS — and left the
binaries in the working tree.

That meant the versions under test were whatever that script last managed
to build on your particular machine, if it built at all, so nothing about
a failure could be attributed with confidence. It also put the suite out
of reach on any machine where you would rather not compile an FTP daemon.

They are containers now. proftpd 1.3.8 from Debian, pure-ftpd 1.0.54
built once in the image — twice from the same source, because implicit
TLS is a compile-time option no distribution packages, which is the only
reason anything is still compiled at all.

    ./scripts/test-servers.sh test

Docker is the only prerequisite.

FTP negotiates transfers by address. A passive transfer has the server
tell the client where to connect; an active one has the server connect
back. A server behind published ports names an address the host cannot
route to, so passive transfers time out and active ones are refused
outright — "I won't open a connection to 127.0.0.1 (only to 172.21.0.1)".

Sharing a network makes the address the server names the address the
client can reach. The ports are published anyway, so `go test` from the
host still works for everything that is not a data transfer.

It used to spawn the binaries it had just compiled. Now it connects to
whatever is listening and, if nothing is, sets every server-dependent
test to skip with a reason — rather than failing on a connection
refused, which reads like a bug in the package instead of a missing
prerequisite. GOFTP_SKIP_SERVERS forces that path, and CI uses it to
prove the server-free tests really are server-free.

The consequence worth knowing: the suite no longer owns the servers'
lifetime, so a test must not leave one in a state the next cannot use.

The certificate is generated at image build time. The committed one
expired in 2015; the tests skip verification, so nobody noticed — which
is the argument against keeping it rather than for it.

pure-ftpd's anonymous root comes from FTP_ANON_DIR in the compose file.
The implicit-TLS test logs in anonymously, and without it that login
lands in pure-ftpd's compiled-in /ftp and cannot chdir.

Stat's assertion on the root directory's name is gone. Every server
describes the same directory and they disagree about what it is called:
proftpd says "testroot", pure-ftpd 1.0.36 said "/", 1.0.54 says ".".
There is no right answer to hold them to, so the name is checked against
that set instead and the fields that do have a right answer are still
compared. A directory's modification time is no longer compared either —
it moves whenever the directory changes, which this suite does
constantly, so comparing it raced with the suite's own writes.

Config.Timeout for the suite is 30 seconds rather than the package
default of 5. Five was ample for processes on the same machine; across a
bridge network that on macOS sits inside a virtual machine, tests failed
intermittently at almost exactly five seconds — a different handful each
run. A test timing out is not a test failing, and a suite that does the
first while reporting the second is worse than a slow one.

.travis.yml tested Go 1.10 and "tip" and ran the compile script. The
workflow replacing it runs the containers, and pins both entries of its
matrix: the floor go.mod declares, and a current release. "stable" moves
on its own, which makes a build green yesterday and red today with no
commit in between.

Verified: the full suite passes on Go 1.21 and 1.25, three consecutive
runs each, and the server-free subset passes with no containers at all.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant