From 93442526f8e4d1197428474207189e4817bcffb0 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Thu, 30 Oct 2025 03:44:31 +0100 Subject: [PATCH] chore(ci): run container and wait for it to connect (#2956) - Added safety to prevent panics/errors when skipping CI checks (shame on me, sometimes) - Opens new possibilities for end to end integration tests. PRs accepted! --- .github/workflows/ci.yml | 27 ++++++ .golangci.yml | 4 + ci/cmd/main.go | 33 +++++++ ci/go.mod | 36 ++++++++ ci/go.sum | 97 ++++++++++++++++++++ ci/internal/mullvad.go | 193 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 390 insertions(+) create mode 100644 ci/cmd/main.go create mode 100644 ci/go.mod create mode 100644 ci/go.sum create mode 100644 ci/internal/mullvad.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0918bafa..2dcb944b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,33 @@ jobs: - name: Build final image run: docker build -t final-image . + verify-private: + if: | + github.repository == 'qdm12/gluetun' && + ( + github.event_name == 'push' || + github.event_name == 'release' || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') + ) + needs: [verify] + runs-on: ubuntu-latest + environment: secrets + steps: + - uses: actions/checkout@v5 + + - run: docker build -t qmcgaw/gluetun . + + - name: Setup Go for CI utility + uses: actions/setup-go@v5 + with: + go-version-file: ci/go.mod + + - name: Build utility + run: go build -C ./ci -o runner ./cmd/main.go + + - name: Run Gluetun container with Mullvad configuration + run: echo -e "${{ secrets.MULLVAD_WIREGUARD_PRIVATE_KEY }}\n${{ secrets.MULLVAD_WIREGUARD_ADDRESS }}" | ./ci/runner mullvad + codeql: runs-on: ubuntu-latest permissions: diff --git a/.golangci.yml b/.golangci.yml index 253eae93..9125581d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -56,6 +56,10 @@ linters: - revive path: internal\/provider\/(common|utils)\/.+\.go text: "var-naming: avoid (bad|meaningless) package names" + - linters: + - err113 + - mnd + path: ci\/.+\.go paths: - third_party$ diff --git a/ci/cmd/main.go b/ci/cmd/main.go new file mode 100644 index 00000000..23b55a06 --- /dev/null +++ b/ci/cmd/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/qdm12/gluetun/ci/internal" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: " + os.Args[0] + " ") + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + + var err error + switch os.Args[1] { + case "mullvad": + err = internal.MullvadTest(ctx) + default: + err = fmt.Errorf("unknown command: %s", os.Args[1]) + } + stop() + if err != nil { + fmt.Println("❌", err) + os.Exit(1) + } + fmt.Println("✅ Test completed successfully.") +} diff --git a/ci/go.mod b/ci/go.mod new file mode 100644 index 00000000..e241468b --- /dev/null +++ b/ci/go.mod @@ -0,0 +1,36 @@ +module github.com/qdm12/gluetun/ci + +go 1.25.0 + +require ( + github.com/docker/docker v28.5.1+incompatible + github.com/opencontainers/image-spec v1.1.1 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/time v0.14.0 // indirect + gotest.tools/v3 v3.5.2 // indirect +) diff --git a/ci/go.sum b/ci/go.sum new file mode 100644 index 00000000..830a19f3 --- /dev/null +++ b/ci/go.sum @@ -0,0 +1,97 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/ci/internal/mullvad.go b/ci/internal/mullvad.go new file mode 100644 index 00000000..1c1881ad --- /dev/null +++ b/ci/internal/mullvad.go @@ -0,0 +1,193 @@ +package internal + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "regexp" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + v1 "github.com/opencontainers/image-spec/specs-go/v1" +) + +func MullvadTest(ctx context.Context) error { + secrets, err := readSecrets(ctx) + if err != nil { + return fmt.Errorf("reading secrets: %w", err) + } + + const timeout = 15 * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + client, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return fmt.Errorf("creating Docker client: %w", err) + } + defer client.Close() + + config := &container.Config{ + Image: "qmcgaw/gluetun", + StopTimeout: ptrTo(3), + Env: []string{ + "VPN_SERVICE_PROVIDER=mullvad", + "VPN_TYPE=wireguard", + "LOG_LEVEL=debug", + "SERVER_COUNTRIES=USA", + "WIREGUARD_PRIVATE_KEY=" + secrets.mullvadWireguardPrivateKey, + "WIREGUARD_ADDRESSES=" + secrets.mullvadWireguardAddress, + }, + } + hostConfig := &container.HostConfig{ + AutoRemove: true, + CapAdd: []string{"NET_ADMIN", "NET_RAW"}, + } + networkConfig := (*network.NetworkingConfig)(nil) + platform := (*v1.Platform)(nil) + const containerName = "" // auto-generated name + + response, err := client.ContainerCreate(ctx, config, hostConfig, networkConfig, platform, containerName) + if err != nil { + return fmt.Errorf("creating container: %w", err) + } + for _, warning := range response.Warnings { + fmt.Println("Warning during container creation:", warning) + } + containerID := response.ID + defer stopContainer(client, containerID) + + beforeStartTime := time.Now() + + err = client.ContainerStart(ctx, containerID, container.StartOptions{}) + if err != nil { + return fmt.Errorf("starting container: %w", err) + } + + return waitForLogLine(ctx, client, containerID, beforeStartTime) +} + +func ptrTo[T any](v T) *T { return &v } + +type secrets struct { + mullvadWireguardPrivateKey string + mullvadWireguardAddress string +} + +func readSecrets(ctx context.Context) (secrets, error) { + expectedSecrets := [...]string{ + "Mullvad Wireguard private key", + "Mullvad Wireguard address", + } + + scanner := bufio.NewScanner(os.Stdin) + lines := make([]string, 0, len(expectedSecrets)) + + for i := range expectedSecrets { + fmt.Println("🤫 reading", expectedSecrets[i], "from Stdin...") + if !scanner.Scan() { + break + } + lines = append(lines, strings.TrimSpace(scanner.Text())) + if ctx.Err() != nil { + return secrets{}, ctx.Err() + } + } + + if err := scanner.Err(); err != nil { + return secrets{}, fmt.Errorf("reading secrets from stdin: %w", err) + } + + if len(lines) < len(expectedSecrets) { + return secrets{}, fmt.Errorf("expected %d secrets via Stdin, but only received %d", + len(expectedSecrets), len(lines)) + } + for i, line := range lines { + if line == "" { + return secrets{}, fmt.Errorf("secret on line %d/%d was empty", i+1, len(lines)) + } + } + + return secrets{ + mullvadWireguardPrivateKey: lines[0], + mullvadWireguardAddress: lines[1], + }, nil +} + +func stopContainer(client *client.Client, containerID string) { + const stopTimeout = 5 * time.Second // must be higher than 3s, see above [container.Config]'s StopTimeout field + stopCtx, stopCancel := context.WithTimeout(context.Background(), stopTimeout) + defer stopCancel() + + err := client.ContainerStop(stopCtx, containerID, container.StopOptions{}) + if err != nil { + fmt.Println("failed to stop container:", err) + } +} + +var successRegexp = regexp.MustCompile(`^.+Public IP address is .+$`) + +func waitForLogLine(ctx context.Context, client *client.Client, containerID string, + beforeStartTime time.Time, +) error { + logOptions := container.LogsOptions{ + ShowStdout: true, + Follow: true, + Since: beforeStartTime.Format(time.RFC3339Nano), + } + + reader, err := client.ContainerLogs(ctx, containerID, logOptions) + if err != nil { + return fmt.Errorf("error getting container logs: %w", err) + } + defer reader.Close() + + var linesSeen []string + scanner := bufio.NewScanner(reader) + for ctx.Err() == nil { + if scanner.Scan() { + line := scanner.Text() + if len(line) > 8 { // remove Docker log prefix + line = line[8:] + } + linesSeen = append(linesSeen, line) + if successRegexp.MatchString(line) { + fmt.Println("✅ Success line logged") + return nil + } + continue + } + err := scanner.Err() + if err != nil && err != io.EOF { + logSeenLines(linesSeen) + return fmt.Errorf("reading log stream: %w", err) + } + + // The scanner is either done or cannot read because of EOF + fmt.Println("The log scanner stopped") + logSeenLines(linesSeen) + + // Check if the container is still running + inspect, err := client.ContainerInspect(ctx, containerID) + if err != nil { + return fmt.Errorf("inspecting container: %w", err) + } + if !inspect.State.Running { + return fmt.Errorf("container stopped unexpectedly while waiting for log line. Exit code: %d", inspect.State.ExitCode) + } + } + + return ctx.Err() +} + +func logSeenLines(lines []string) { + fmt.Println("Logs seen so far:") + for _, line := range lines { + fmt.Println(" " + line) + } +}