Repository: containrrr/watchtower Branch: main Commit: ca0e86e824ec Files: 165 Total size: 19.7 MB Directory structure: gitextract_hq806g1o/ ├── .all-contributorsrc ├── .codacy.yml ├── .devbots/ │ └── lock-issue.yml ├── .editorconfig ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── dependabot.yml │ ├── pull_request_template.md │ ├── stale.yml │ └── workflows/ │ ├── codeql-analysis.yml │ ├── dependabot-approve.yml │ ├── greetings.yml │ ├── publish-docs.yml │ ├── pull-request.yml │ ├── release-dev.yaml │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── build.sh ├── cmd/ │ ├── notify-upgrade.go │ └── root.go ├── code_of_conduct.md ├── docker-compose.yml ├── dockerfiles/ │ ├── Dockerfile │ ├── Dockerfile.dev-self-contained │ ├── Dockerfile.self-contained │ └── container-networking/ │ └── docker-compose.yml ├── docs/ │ ├── arguments.md │ ├── container-selection.md │ ├── http-api-mode.md │ ├── index.md │ ├── introduction.md │ ├── lifecycle-hooks.md │ ├── linked-containers.md │ ├── metrics.md │ ├── notifications.md │ ├── private-registries.md │ ├── remote-hosts.md │ ├── running-multiple-instances.md │ ├── secure-connections.md │ ├── stop-signals.md │ ├── stylesheets/ │ │ └── theme.css │ ├── template-preview.md │ ├── updating.md │ └── usage-overview.md ├── docs-requirements.txt ├── go.mod ├── go.sum ├── goreleaser.yml ├── grafana/ │ ├── dashboards/ │ │ ├── dashboard.json │ │ └── dashboard.yml │ └── datasources/ │ └── datasource.yml ├── internal/ │ ├── actions/ │ │ ├── actions_suite_test.go │ │ ├── check.go │ │ ├── mocks/ │ │ │ ├── client.go │ │ │ ├── container.go │ │ │ └── progress.go │ │ ├── update.go │ │ └── update_test.go │ ├── flags/ │ │ ├── flags.go │ │ └── flags_test.go │ ├── meta/ │ │ └── meta.go │ └── util/ │ ├── rand_name.go │ ├── rand_sha256.go │ ├── util.go │ └── util_test.go ├── main.go ├── mkdocs.yml ├── oryxBuildBinary ├── pkg/ │ ├── api/ │ │ ├── api.go │ │ ├── api_test.go │ │ ├── metrics/ │ │ │ ├── metrics.go │ │ │ └── metrics_test.go │ │ └── update/ │ │ └── update.go │ ├── container/ │ │ ├── cgroup_id.go │ │ ├── cgroup_id_test.go │ │ ├── client.go │ │ ├── client_test.go │ │ ├── container.go │ │ ├── container_mock_test.go │ │ ├── container_suite_test.go │ │ ├── container_test.go │ │ ├── errors.go │ │ ├── metadata.go │ │ ├── mocks/ │ │ │ ├── ApiServer.go │ │ │ ├── FilterableContainer.go │ │ │ ├── container_ref.go │ │ │ └── data/ │ │ │ ├── container_net_consumer-missing_supplier.json │ │ │ ├── container_net_consumer.json │ │ │ ├── container_net_supplier.json │ │ │ ├── container_restarting.json │ │ │ ├── container_running.json │ │ │ ├── container_stopped.json │ │ │ ├── container_watchtower.json │ │ │ ├── containers.json │ │ │ ├── image_default.json │ │ │ ├── image_net_consumer.json │ │ │ ├── image_net_producer.json │ │ │ └── image_running.json │ │ └── util_test.go │ ├── filters/ │ │ ├── filters.go │ │ └── filters_test.go │ ├── lifecycle/ │ │ └── lifecycle.go │ ├── metrics/ │ │ └── metrics.go │ ├── notifications/ │ │ ├── common_templates.go │ │ ├── email.go │ │ ├── gotify.go │ │ ├── json.go │ │ ├── json_test.go │ │ ├── model.go │ │ ├── msteams.go │ │ ├── notifications_suite_test.go │ │ ├── notifier.go │ │ ├── notifier_test.go │ │ ├── preview/ │ │ │ ├── data/ │ │ │ │ ├── data.go │ │ │ │ ├── logs.go │ │ │ │ ├── preview_strings.go │ │ │ │ ├── report.go │ │ │ │ └── status.go │ │ │ └── tplprev.go │ │ ├── shoutrrr.go │ │ ├── shoutrrr_test.go │ │ ├── slack.go │ │ └── templates/ │ │ └── funcs.go │ ├── registry/ │ │ ├── auth/ │ │ │ ├── auth.go │ │ │ └── auth_test.go │ │ ├── digest/ │ │ │ ├── digest.go │ │ │ └── digest_test.go │ │ ├── helpers/ │ │ │ ├── helpers.go │ │ │ └── helpers_test.go │ │ ├── manifest/ │ │ │ ├── manifest.go │ │ │ └── manifest_test.go │ │ ├── registry.go │ │ ├── registry_suite_test.go │ │ ├── registry_test.go │ │ ├── trust.go │ │ └── trust_test.go │ ├── session/ │ │ ├── container_status.go │ │ ├── progress.go │ │ └── report.go │ ├── sorter/ │ │ └── sort.go │ └── types/ │ ├── container.go │ ├── convertible_notifier.go │ ├── filter.go │ ├── filterable_container.go │ ├── notifier.go │ ├── registry_credentials.go │ ├── report.go │ ├── token_response.go │ └── update_params.go ├── prometheus/ │ └── prometheus.yml ├── scripts/ │ ├── build-tplprev.sh │ ├── codecov.sh │ ├── contnet-tests.sh │ ├── dependency-test.sh │ ├── docker-util.sh │ ├── du-cli.sh │ └── lifecycle-tests.sh └── tplprev/ ├── main.go └── main_wasm.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "files": [ "README.md" ], "imageSize": 100, "commit": false, "contributors": [ { "login": "piksel", "name": "nils måsén", "avatar_url": "https://avatars2.githubusercontent.com/u/807383?v=4", "profile": "https://piksel.se", "contributions": [ "code", "doc", "maintenance", "review" ] }, { "login": "simskij", "name": "Simon Aronsson", "avatar_url": "https://avatars0.githubusercontent.com/u/1596025?v=4", "profile": "http://simme.dev", "contributions": [ "code", "doc", "maintenance", "review" ] }, { "login": "Codelica", "name": "James", "avatar_url": "https://avatars3.githubusercontent.com/u/386101?v=4", "profile": "http://codelica.com", "contributions": [ "test", "ideas" ] }, { "login": "KopfKrieg", "name": "Florian", "avatar_url": "https://avatars2.githubusercontent.com/u/5047813?v=4", "profile": "https://kopfkrieg.org", "contributions": [ "review", "doc" ] }, { "login": "bdehamer", "name": "Brian DeHamer", "avatar_url": "https://avatars1.githubusercontent.com/u/398027?v=4", "profile": "https://github.com/bdehamer", "contributions": [ "code", "maintenance" ] }, { "login": "rosscado", "name": "Ross Cadogan", "avatar_url": "https://avatars1.githubusercontent.com/u/16578183?v=4", "profile": "https://github.com/rosscado", "contributions": [ "code" ] }, { "login": "stffabi", "name": "stffabi", "avatar_url": "https://avatars0.githubusercontent.com/u/9464631?v=4", "profile": "https://github.com/stffabi", "contributions": [ "code", "maintenance" ] }, { "login": "ATCUSA", "name": "Austin", "avatar_url": "https://avatars3.githubusercontent.com/u/3581228?v=4", "profile": "https://github.com/ATCUSA", "contributions": [ "doc" ] }, { "login": "davidgardner11", "name": "David Gardner", "avatar_url": "https://avatars2.githubusercontent.com/u/6181487?v=4", "profile": "https://labs.ctl.io", "contributions": [ "review", "doc" ] }, { "login": "dolanor", "name": "Tanguy ⧓ Herrmann", "avatar_url": "https://avatars3.githubusercontent.com/u/928722?v=4", "profile": "https://github.com/dolanor", "contributions": [ "code" ] }, { "login": "rdamazio", "name": "Rodrigo Damazio Bovendorp", "avatar_url": "https://avatars3.githubusercontent.com/u/997641?v=4", "profile": "https://github.com/rdamazio", "contributions": [ "code", "doc" ] }, { "login": "thelamer", "name": "Ryan Kuba", "avatar_url": "https://avatars3.githubusercontent.com/u/1852688?v=4", "profile": "https://www.taisun.io/", "contributions": [ "infra" ] }, { "login": "cnrmck", "name": "cnrmck", "avatar_url": "https://avatars2.githubusercontent.com/u/22061955?v=4", "profile": "https://github.com/cnrmck", "contributions": [ "doc" ] }, { "login": "haswalt", "name": "Harry Walter", "avatar_url": "https://avatars3.githubusercontent.com/u/338588?v=4", "profile": "http://harrywalter.co.uk", "contributions": [ "code" ] }, { "login": "Robotex", "name": "Robotex", "avatar_url": "https://avatars3.githubusercontent.com/u/74515?v=4", "profile": "http://projectsperanza.com", "contributions": [ "doc" ] }, { "login": "ubergesundheit", "name": "Gerald Pape", "avatar_url": "https://avatars0.githubusercontent.com/u/1494211?v=4", "profile": "http://geraldpape.io", "contributions": [ "doc" ] }, { "login": "fomk", "name": "fomk", "avatar_url": "https://avatars0.githubusercontent.com/u/17636183?v=4", "profile": "https://github.com/fomk", "contributions": [ "code" ] }, { "login": "svengo", "name": "Sven Gottwald", "avatar_url": "https://avatars3.githubusercontent.com/u/2502366?v=4", "profile": "https://github.com/svengo", "contributions": [ "infra" ] }, { "login": "techknowlogick", "name": "techknowlogick", "avatar_url": "https://avatars1.githubusercontent.com/u/164197?v=4", "profile": "https://liberapay.com/techknowlogick/", "contributions": [ "code" ] }, { "login": "waja", "name": "waja", "avatar_url": "https://avatars1.githubusercontent.com/u/1449568?v=4", "profile": "http://log.c5t.org/about/", "contributions": [ "doc" ] }, { "login": "salbertson", "name": "Scott Albertson", "avatar_url": "https://avatars2.githubusercontent.com/u/154463?v=4", "profile": "http://scottalbertson.com", "contributions": [ "doc" ] }, { "login": "huddlesj", "name": "Jason Huddleston", "avatar_url": "https://avatars1.githubusercontent.com/u/11966535?v=4", "profile": "https://github.com/huddlesj", "contributions": [ "doc" ] }, { "login": "napstr", "name": "Napster", "avatar_url": "https://avatars3.githubusercontent.com/u/6048348?v=4", "profile": "https://npstr.space/", "contributions": [ "code" ] }, { "login": "darknode", "name": "Maxim", "avatar_url": "https://avatars1.githubusercontent.com/u/809429?v=4", "profile": "https://github.com/darknode", "contributions": [ "code", "doc" ] }, { "login": "mxschmitt", "name": "Max Schmitt", "avatar_url": "https://avatars0.githubusercontent.com/u/17984549?v=4", "profile": "https://schmitt.cat", "contributions": [ "doc" ] }, { "login": "cron410", "name": "cron410", "avatar_url": "https://avatars1.githubusercontent.com/u/3082899?v=4", "profile": "https://github.com/cron410", "contributions": [ "doc" ] }, { "login": "Cardoso222", "name": "Paulo Henrique", "avatar_url": "https://avatars3.githubusercontent.com/u/7026517?v=4", "profile": "https://github.com/Cardoso222", "contributions": [ "doc" ] }, { "login": "belak", "name": "Kaleb Elwert", "avatar_url": "https://avatars0.githubusercontent.com/u/107097?v=4", "profile": "https://coded.io", "contributions": [ "doc" ] }, { "login": "wmbutler", "name": "Bill Butler", "avatar_url": "https://avatars1.githubusercontent.com/u/1254810?v=4", "profile": "https://github.com/wmbutler", "contributions": [ "doc" ] }, { "login": "mariotacke", "name": "Mario Tacke", "avatar_url": "https://avatars2.githubusercontent.com/u/4942019?v=4", "profile": "https://www.mariotacke.io", "contributions": [ "code" ] }, { "login": "mrw34", "name": "Mark Woodbridge", "avatar_url": "https://avatars2.githubusercontent.com/u/1101318?v=4", "profile": "https://markwoodbridge.com", "contributions": [ "code" ] }, { "login": "Ansem93", "name": "Ansem93", "avatar_url": "https://avatars3.githubusercontent.com/u/6626218?v=4", "profile": "https://github.com/Ansem93", "contributions": [ "doc" ] }, { "login": "lukapeschke", "name": "Luka Peschke", "avatar_url": "https://avatars1.githubusercontent.com/u/17085536?v=4", "profile": "https://github.com/lukapeschke", "contributions": [ "code", "doc" ] }, { "login": "zoispag", "name": "Zois Pagoulatos", "avatar_url": "https://avatars0.githubusercontent.com/u/21138205?v=4", "profile": "https://github.com/zoispag", "contributions": [ "code", "review", "maintenance" ] }, { "login": "alexandremenif", "name": "Alexandre Menif", "avatar_url": "https://avatars0.githubusercontent.com/u/16152103?v=4", "profile": "https://alexandre.menif.name", "contributions": [ "code" ] }, { "login": "chugunov", "name": "Andrey", "avatar_url": "https://avatars1.githubusercontent.com/u/4140479?v=4", "profile": "https://github.com/chugunov", "contributions": [ "doc" ] }, { "login": "noplanman", "name": "Armando Lüscher", "avatar_url": "https://avatars3.githubusercontent.com/u/9423417?v=4", "profile": "https://noplanman.ch", "contributions": [ "doc" ] }, { "login": "rjbudke", "name": "Ryan Budke", "avatar_url": "https://avatars2.githubusercontent.com/u/273485?v=4", "profile": "https://github.com/rjbudke", "contributions": [ "doc" ] }, { "login": "kaloyan-raev", "name": "Kaloyan Raev", "avatar_url": "https://avatars2.githubusercontent.com/u/468091?v=4", "profile": "http://kaloyan.raev.name", "contributions": [ "code", "test" ] }, { "login": "sixth", "name": "sixth", "avatar_url": "https://avatars3.githubusercontent.com/u/11591445?v=4", "profile": "https://github.com/sixth", "contributions": [ "doc" ] }, { "login": "foosel", "name": "Gina Häußge", "avatar_url": "https://avatars0.githubusercontent.com/u/83657?v=4", "profile": "https://foosel.net", "contributions": [ "code" ] }, { "login": "8ear", "name": "Max H.", "avatar_url": "https://avatars0.githubusercontent.com/u/10329648?v=4", "profile": "https://github.com/8ear", "contributions": [ "code" ] }, { "login": "pjknkda", "name": "Jungkook Park", "avatar_url": "https://avatars0.githubusercontent.com/u/4986524?v=4", "profile": "https://pjknkda.github.io", "contributions": [ "doc" ] }, { "login": "jnidzwetzki", "name": "Jan Kristof Nidzwetzki", "avatar_url": "https://avatars1.githubusercontent.com/u/5753622?v=4", "profile": "https://achfrag.net", "contributions": [ "doc" ] }, { "login": "mindrunner", "name": "lukas", "avatar_url": "https://avatars0.githubusercontent.com/u/1413542?v=4", "profile": "https://www.lukaselsner.de", "contributions": [ "code" ] }, { "login": "codingCoffee", "name": "Ameya Shenoy", "avatar_url": "https://avatars3.githubusercontent.com/u/13611153?v=4", "profile": "https://codingcoffee.dev", "contributions": [ "code" ] }, { "login": "raymondelooff", "name": "Raymon de Looff", "avatar_url": "https://avatars0.githubusercontent.com/u/9716806?v=4", "profile": "https://github.com/raymondelooff", "contributions": [ "code" ] }, { "login": "jsclayton", "name": "John Clayton", "avatar_url": "https://avatars2.githubusercontent.com/u/704034?v=4", "profile": "http://codemonkeylabs.com", "contributions": [ "code" ] }, { "login": "Germs2004", "name": "Germs2004", "avatar_url": "https://avatars2.githubusercontent.com/u/5519340?v=4", "profile": "https://github.com/Germs2004", "contributions": [ "doc" ] }, { "login": "lukwil", "name": "Lukas Willburger", "avatar_url": "https://avatars1.githubusercontent.com/u/30203234?v=4", "profile": "https://github.com/lukwil", "contributions": [ "code" ] }, { "login": "auanasgheps", "name": "Oliver Cervera", "avatar_url": "https://avatars2.githubusercontent.com/u/20586878?v=4", "profile": "https://github.com/auanasgheps", "contributions": [ "doc" ] }, { "login": "victorcmoura", "name": "Victor Moura", "avatar_url": "https://avatars1.githubusercontent.com/u/26290053?v=4", "profile": "https://github.com/victorcmoura", "contributions": [ "test", "code", "doc" ] }, { "login": "mbrandau", "name": "Maximilian Brandau", "avatar_url": "https://avatars3.githubusercontent.com/u/12972798?v=4", "profile": "https://github.com/mbrandau", "contributions": [ "code", "test" ] }, { "login": "aneisch", "name": "Andrew", "avatar_url": "https://avatars1.githubusercontent.com/u/6991461?v=4", "profile": "https://github.com/aneisch", "contributions": [ "doc" ] }, { "login": "sixcorners", "name": "sixcorners", "avatar_url": "https://avatars0.githubusercontent.com/u/585501?v=4", "profile": "https://github.com/sixcorners", "contributions": [ "doc" ] }, { "login": "arnested", "name": "Arne Jørgensen", "avatar_url": "https://avatars2.githubusercontent.com/u/190005?v=4", "profile": "https://arnested.dk", "contributions": [ "test", "review" ] }, { "login": "patski123", "name": "PatSki123", "avatar_url": "https://avatars1.githubusercontent.com/u/19295295?v=4", "profile": "https://github.com/patski123", "contributions": [ "doc" ] }, { "login": "Saicheg", "name": "Valentine Zavadsky", "avatar_url": "https://avatars2.githubusercontent.com/u/624999?v=4", "profile": "https://rubyroidlabs.com/", "contributions": [ "code", "doc", "test" ] }, { "login": "bopoh24", "name": "Alexander Voronin", "avatar_url": "https://avatars2.githubusercontent.com/u/4086631?v=4", "profile": "https://github.com/bopoh24", "contributions": [ "code", "bug" ] }, { "login": "ogmueller", "name": "Oliver Mueller", "avatar_url": "https://avatars0.githubusercontent.com/u/788989?v=4", "profile": "http://www.teqneers.de", "contributions": [ "doc" ] }, { "login": "tammert", "name": "Sebastiaan Tammer", "avatar_url": "https://avatars0.githubusercontent.com/u/8885250?v=4", "profile": "https://github.com/tammert", "contributions": [ "code" ] }, { "login": "miosame", "name": "miosame", "avatar_url": "https://avatars1.githubusercontent.com/u/8201077?v=4", "profile": "https://github.com/Miosame", "contributions": [ "doc" ] }, { "login": "andrewjmetzger", "name": "Andrew Metzger", "avatar_url": "https://avatars3.githubusercontent.com/u/590246?v=4", "profile": "https://mtz.gr", "contributions": [ "bug", "example" ] }, { "login": "pgrimaud", "name": "Pierre Grimaud", "avatar_url": "https://avatars1.githubusercontent.com/u/1866496?v=4", "profile": "https://github.com/pgrimaud", "contributions": [ "doc" ] }, { "login": "mattdoran", "name": "Matt Doran", "avatar_url": "https://avatars0.githubusercontent.com/u/577779?v=4", "profile": "https://github.com/mattdoran", "contributions": [ "doc" ] }, { "login": "MihailITPlace", "name": "MihailITPlace", "avatar_url": "https://avatars2.githubusercontent.com/u/28401551?v=4", "profile": "https://github.com/MihailITPlace", "contributions": [ "code" ] }, { "login": "bugficks", "name": "bugficks", "avatar_url": "https://avatars1.githubusercontent.com/u/2992895?v=4", "profile": "https://github.com/bugficks", "contributions": [ "code", "doc" ] }, { "login": "MichaelSp", "name": "Michael", "avatar_url": "https://avatars0.githubusercontent.com/u/448282?v=4", "profile": "https://github.com/MichaelSp", "contributions": [ "code" ] }, { "login": "jokay", "name": "D. Domig", "avatar_url": "https://avatars0.githubusercontent.com/u/18613935?v=4", "profile": "https://github.com/jokay", "contributions": [ "doc" ] }, { "login": "osheroff", "name": "Ben Osheroff", "avatar_url": "https://avatars1.githubusercontent.com/u/260084?v=4", "profile": "https://maxwells-daemon.io", "contributions": [ "code" ] }, { "login": "dhet", "name": "David H.", "avatar_url": "https://avatars3.githubusercontent.com/u/2668621?v=4", "profile": "https://github.com/dhet", "contributions": [ "code" ] }, { "login": "chander", "name": "Chander Ganesan", "avatar_url": "https://avatars1.githubusercontent.com/u/671887?v=4", "profile": "http://www.gridgeo.com", "contributions": [ "doc" ] }, { "login": "yrien30", "name": "yrien30", "avatar_url": "https://avatars1.githubusercontent.com/u/26816162?v=4", "profile": "https://github.com/yrien30", "contributions": [ "code" ] }, { "login": "ksurl", "name": "ksurl", "avatar_url": "https://avatars1.githubusercontent.com/u/1371562?v=4", "profile": "https://github.com/ksurl", "contributions": [ "doc", "code", "infra" ] }, { "login": "rg9400", "name": "rg9400", "avatar_url": "https://avatars2.githubusercontent.com/u/39887349?v=4", "profile": "https://github.com/rg9400", "contributions": [ "code" ] }, { "login": "tkalus", "name": "Turtle Kalus", "avatar_url": "https://avatars2.githubusercontent.com/u/287181?v=4", "profile": "https://github.com/tkalus", "contributions": [ "code" ] }, { "login": "SrihariThalla", "name": "Srihari Thalla", "avatar_url": "https://avatars1.githubusercontent.com/u/7479937?v=4", "profile": "https://github.com/SrihariThalla", "contributions": [ "doc" ] }, { "login": "nymous", "name": "Thomas Gaudin", "avatar_url": "https://avatars1.githubusercontent.com/u/4216559?v=4", "profile": "https://nymous.io", "contributions": [ "doc" ] }, { "login": "hydrargyrum", "name": "hydrargyrum", "avatar_url": "https://avatars.githubusercontent.com/u/2804645?v=4", "profile": "https://indigo.re/", "contributions": [ "doc" ] }, { "login": "reinout", "name": "Reinout van Rees", "avatar_url": "https://avatars.githubusercontent.com/u/121433?v=4", "profile": "https://reinout.vanrees.org", "contributions": [ "doc" ] }, { "login": "DasSkelett", "name": "DasSkelett", "avatar_url": "https://avatars.githubusercontent.com/u/28812678?v=4", "profile": "https://github.com/DasSkelett", "contributions": [ "code" ] }, { "login": "zenjabba", "name": "zenjabba", "avatar_url": "https://avatars.githubusercontent.com/u/679864?v=4", "profile": "https://github.com/zenjabba", "contributions": [ "doc" ] }, { "login": "djquan", "name": "Dan Quan", "avatar_url": "https://avatars.githubusercontent.com/u/3526705?v=4", "profile": "https://quan.io", "contributions": [ "doc" ] }, { "login": "modem7", "name": "modem7", "avatar_url": "https://avatars.githubusercontent.com/u/4349962?v=4", "profile": "https://github.com/modem7", "contributions": [ "doc" ] }, { "login": "hypnoglow", "name": "Igor Zibarev", "avatar_url": "https://avatars.githubusercontent.com/u/4853075?v=4", "profile": "https://github.com/hypnoglow", "contributions": [ "code" ] }, { "login": "patricegautier", "name": "Patrice", "avatar_url": "https://avatars.githubusercontent.com/u/38435239?v=4", "profile": "https://github.com/patricegautier", "contributions": [ "code" ] }, { "login": "jamesmacwhite", "name": "James White", "avatar_url": "https://avatars.githubusercontent.com/u/8067792?v=4", "profile": "http://jamesw.link/me", "contributions": [ "doc" ] }, { "login": "Foxite", "name": "Dirk Kok", "avatar_url": "https://avatars.githubusercontent.com/u/20421657?v=4", "profile": "https://ko-fi.com/foxite", "contributions": [ "code" ] }, { "login": "EDIflyer", "name": "EDIflyer", "avatar_url": "https://avatars.githubusercontent.com/u/13610277?v=4", "profile": "https://github.com/EDIflyer", "contributions": [ "doc" ] }, { "login": "jauderho", "name": "Jauder Ho", "avatar_url": "https://avatars.githubusercontent.com/u/13562?v=4", "profile": "https://github.com/jauderho", "contributions": [ "code" ] }, { "login": "andriibratanin", "name": "Andrii Bratanin", "avatar_url": "https://avatars.githubusercontent.com/u/20169213?v=4", "profile": "https://github.com/andriibratanin" }, { "login": "IAmTamal", "name": "Tamal Das ", "avatar_url": "https://avatars.githubusercontent.com/u/72851613?v=4", "profile": "https://tamal.vercel.app/", "contributions": [ "doc" ] }, { "login": "testwill", "name": "guangwu", "avatar_url": "https://avatars.githubusercontent.com/u/8717479?v=4", "profile": "https://github.com/testwill", "contributions": [ "doc" ] }, { "login": "nothub", "name": "Florian Hübner", "avatar_url": "https://avatars.githubusercontent.com/u/48992448?v=4", "profile": "http://hub.lol", "contributions": [ "doc", "code" ] } ], "contributorsPerLine": 7, "projectName": "watchtower", "projectOwner": "containrrr", "repoType": "github", "repoHost": "https://github.com", "commitConvention": "none", "skipCi": true, "commitType": "docs" } ================================================ FILE: .codacy.yml ================================================ --- engines: coverage: exclude_paths: - "*.md" - "**/*.md" ================================================ FILE: .devbots/lock-issue.yml ================================================ enabled: true comment: > To avoid important communication to get lost in a closed issues no one monitors, I'll go ahead and lock this issue. If you want to continue the discussion, please open a new issue. Thank you! 🙏🏼 ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true charset = utf-8 [*.css] indent_style = space indent_size = 2 [{go.mod,go.sum,*.go}] indent_style = tab indent_size = 4 ================================================ FILE: .github/CODEOWNERS ================================================ pkg/notifications/smtp.go @piksel pkg/notifications/email.go @piksel pkg/notifications/shoutrrr.go @piksel @simskij @arnested pkg/container/* @simskij pkg/api/* @victorcmoura .devbots/* @simskij .github/* @simskij docs/* @containrrr/watchtower-contributors ================================================ FILE: .github/ISSUE_TEMPLATE/bug.yml ================================================ name: 🐛 Bug report description: Create a report to help us improve labels: ["Priority: Medium, Status: Available, Type: Bug"] body: - type: markdown attributes: value: Before submitting your issue, please make sure you're using the containrrr/watchtower:latest image. If not, switch to this image prior to posting your report. Other forks, or the old `v2tec` image are **not** supported. - type: textarea id: description attributes: label: Describe the bug description: A clear and concise description of what the bug is validations: required: true - type: textarea id: reproduce attributes: label: Steps to reproduce description: Steps to reproduce the behavior value: | 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error validations: required: true - type: textarea id: expected attributes: label: Expected behavior description: A clear and concise description of what you expected to happen. validations: required: true - type: textarea id: screenshots attributes: label: Screenshots description: Please add screenshots if applicable validations: required: false - type: textarea attributes: label: Environment description: We would want to know the following things value: | - Platform - Architecture - Docker Version validations: required: true - type: textarea attributes: label: Your logs description: Paste the logs from running watchtower with the `--debug` option. render: text validations: required: true - type: textarea attributes: label: Additional context description: Add any other context about the problem here. validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Ask a question url: https://github.com/containrrr/watchtower/discussions about: Ask questions and discuss with other community members ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 💡 Feature request description: Have a new idea/feature ? Please suggest! labels: ["Priority: Low, Status: Available, Type: Enhancement"] body: - type: textarea id: description attributes: label: Is your feature request related to a problem? Please describe. description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] validations: required: true - type: textarea id: solution attributes: label: Describe the solution you'd like description: A clear and concise description of what you want to happen. validations: required: true - type: textarea id: alternatives attributes: label: Describe alternatives you've considered description: A clear and concise description of any alternative solutions or features you've considered. validations: required: true - type: textarea id: extrainfo attributes: label: Additional context description: Add any other context or screenshots about the feature request here. validations: required: false ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "github-actions" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" - package-ecosystem: "gomod" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" - package-ecosystem: "docker" # See documentation for possible values directory: "/dockerfiles" # Location of package manifests schedule: interval: "weekly" ================================================ FILE: .github/pull_request_template.md ================================================ ================================================ FILE: .github/stale.yml ================================================ daysUntilStale: 60 daysUntilClose: 7 exemptMilestones: true exemptLabels: - "Public Service Announcement" - "Do not close" - "Type: Bug" - "Type: Security" staleLabel: "Status: Stale" markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. closeComment: false ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. name: "CodeQL" on: push: branches: [main] pull_request: # The branches below must be a subset of the branches above branches: [main] schedule: - cron: '0 1 * * 4' workflow_dispatch: jobs: analyze: name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: # Override automatic language detection by changing the below list # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] language: ['go'] # Learn more... # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection steps: - name: Checkout repository uses: actions/checkout@v4 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. fetch-depth: 2 # If this run was triggered by a pull request event, then checkout # the head of the pull request instead of the merge commit. - run: git checkout HEAD^2 if: ${{ github.event_name == 'pull_request' }} # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 ================================================ FILE: .github/workflows/dependabot-approve.yml ================================================ name: Auto approve dependabot PRs on: pull_request_target jobs: auto-approve: runs-on: ubuntu-latest permissions: pull-requests: write if: github.actor == 'dependabot[bot]' steps: - uses: hmarr/auto-approve-action@v3 ================================================ FILE: .github/workflows/greetings.yml ================================================ name: Greetings on: # Runs in the context of the target (containrrr/watchtower) repository, and as such has access to GITHUB_TOKEN pull_request_target: types: [opened] issues: types: [opened] jobs: greeting: runs-on: ubuntu-latest steps: - uses: actions/first-interaction@v1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} issue-message: > Hi there! 👋🏼 As you're new to this repo, we'd like to suggest that you read our [code of conduct](https://github.com/containrrr/.github/blob/master/CODE_OF_CONDUCT.md) as well as our [contribution guidelines](https://github.com/containrrr/watchtower/blob/master/CONTRIBUTING.md). Thanks a bunch for opening your first issue! 🙏 pr-message: > Congratulations on opening your first pull request! We'll get back to you as soon as possible. In the meantime, please make sure you've updated the documentation to reflect your changes and have added test automation as needed. Thanks! 🙏🏼 ================================================ FILE: .github/workflows/publish-docs.yml ================================================ name: Publish Docs on: workflow_dispatch: { } workflow_run: workflows: [ "Release (Production)" ] branches: [ main ] types: - completed jobs: publish-docs: name: Publish Docs runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: go-version: 1.20.x - name: Build tplprev run: scripts/build-tplprev.sh - name: Setup python uses: actions/setup-python@v5 with: python-version: '3.10' cache: 'pip' cache-dependency-path: | docs-requirements.txt - name: Install mkdocs run: | pip install -r docs-requirements.txt - name: Generate docs run: mkdocs gh-deploy --strict ================================================ FILE: .github/workflows/pull-request.yml ================================================ name: Pull Request on: workflow_dispatch: {} pull_request: branches: - main jobs: lint: name: Lint runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: go-version: 1.20.x - uses: dominikh/staticcheck-action@ba605356b4b29a60e87ab9404b712f3461e566dc #v1.3.0 with: version: "2023.1.6" install-go: "false" # StaticCheck uses go v1.17 which does not support `any` test: name: Test strategy: fail-fast: false matrix: go-version: - 1.20.x platform: - macos-latest - windows-latest - ubuntu-latest runs-on: ${{ matrix.platform }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: go-version: 1.20.x - name: Run tests run: | go test -v -coverprofile coverage.out -covermode atomic ./... - name: Publish coverage uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} build: name: Build runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: go-version: 1.20.x - name: Build uses: goreleaser/goreleaser-action@7ec5c2b0c6cdda6e8bbb49444bc797dd33d74dd8 #v3 with: version: v0.155.0 args: --snapshot --skip-publish --debug ================================================ FILE: .github/workflows/release-dev.yaml ================================================ name: Push to main on: workflow_dispatch: {} push: branches: - main jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: go-version: 1.20.x - name: Build run: ./build.sh test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 with: go-version: 1.20.x - name: Test run: go test -v -coverprofile coverage.out -covermode atomic ./... - name: Publish coverage uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} publish: needs: - build - test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Publish to Docker Hub uses: jerray/publish-docker-action@87d84711629b0dc9f6bb127b568413cc92a2088e #master@2022-10-14 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} file: dockerfiles/Dockerfile.self-contained repository: containrrr/watchtower tags: latest-dev - name: Publish to GHCR uses: jerray/publish-docker-action@87d84711629b0dc9f6bb127b568413cc92a2088e #master@2022-10-14 with: username: ${{ secrets.BOT_USERNAME }} password: ${{ secrets.BOT_GHCR_PAT }} file: dockerfiles/Dockerfile.self-contained registry: ghcr.io repository: containrrr/watchtower tags: latest-dev ================================================ FILE: .github/workflows/release.yml ================================================ name: Release (Production) on: workflow_dispatch: {} push: tags: - 'v[0-9]+.[0-9]+.[0-9]+' - '**/v[0-9]+.[0-9]+.[0-9]+' jobs: lint: name: Lint runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: go-version: 1.20.x - uses: dominikh/staticcheck-action@ba605356b4b29a60e87ab9404b712f3461e566dc #v1.3.0 with: version: "2022.1.1" install-go: "false" # StaticCheck uses go v1.17 which does not support `any` test: name: Test strategy: matrix: go-version: - 1.20.x platform: - ubuntu-latest - macos-latest - windows-latest runs-on: ${{ matrix.platform }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: go-version: 1.20.x - name: Run tests run: | go test ./... -coverprofile coverage.out build: name: Build runs-on: ubuntu-latest needs: - test - lint env: CGO_ENABLED: 0 TAG: ${{ github.ref_name }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: go-version: 1.20.x - name: Login to Docker Hub uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc #v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc #v2 with: username: ${{ secrets.BOT_USERNAME }} password: ${{ secrets.BOT_GHCR_PAT }} registry: ghcr.io - name: Build uses: goreleaser/goreleaser-action@7ec5c2b0c6cdda6e8bbb49444bc797dd33d74dd8 #v3 with: version: v0.155.0 args: --debug env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Enable experimental docker features run: | mkdir -p ~/.docker/ && \ echo '{"experimental": "enabled"}' > ~/.docker/config.json - name: Create manifest for version run: | export DH_TAG=$(git tag --points-at HEAD | sed 's/^v*//') docker manifest create \ containrrr/watchtower:$DH_TAG \ containrrr/watchtower:amd64-$DH_TAG \ containrrr/watchtower:i386-$DH_TAG \ containrrr/watchtower:armhf-$DH_TAG \ containrrr/watchtower:arm64v8-$DH_TAG docker manifest create \ ghcr.io/containrrr/watchtower:$DH_TAG \ ghcr.io/containrrr/watchtower:amd64-$DH_TAG \ ghcr.io/containrrr/watchtower:i386-$DH_TAG \ ghcr.io/containrrr/watchtower:armhf-$DH_TAG \ ghcr.io/containrrr/watchtower:arm64v8-$DH_TAG - name: Annotate manifest for version run: | for REPO in '' ghcr.io/ ; do docker manifest annotate \ ${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \ ${REPO}containrrr/watchtower:i386-$(echo $TAG | sed 's/^v*//') \ --os linux \ --arch 386 docker manifest annotate \ ${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \ ${REPO}containrrr/watchtower:armhf-$(echo $TAG | sed 's/^v*//') \ --os linux \ --arch arm docker manifest annotate \ ${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \ ${REPO}containrrr/watchtower:arm64v8-$(echo $TAG | sed 's/^v*//') \ --os linux \ --arch arm64 \ --variant v8 done - name: Create manifest for latest run: | docker manifest create \ containrrr/watchtower:latest \ containrrr/watchtower:amd64-latest \ containrrr/watchtower:i386-latest \ containrrr/watchtower:armhf-latest \ containrrr/watchtower:arm64v8-latest docker manifest create \ ghcr.io/containrrr/watchtower:latest \ ghcr.io/containrrr/watchtower:amd64-latest \ ghcr.io/containrrr/watchtower:i386-latest \ ghcr.io/containrrr/watchtower:armhf-latest \ ghcr.io/containrrr/watchtower:arm64v8-latest - name: Annotate manifest for latest run: | for REPO in '' ghcr.io/ ; do docker manifest annotate \ ${REPO}containrrr/watchtower:latest \ ${REPO}containrrr/watchtower:i386-latest \ --os linux \ --arch 386 docker manifest annotate \ ${REPO}containrrr/watchtower:latest \ ${REPO}containrrr/watchtower:armhf-latest \ --os linux \ --arch arm docker manifest annotate \ ${REPO}containrrr/watchtower:latest \ ${REPO}containrrr/watchtower:arm64v8-latest \ --os linux \ --arch arm64 \ --variant v8 done - name: Push manifests to Dockerhub env: DOCKER_USER: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} run: | docker login -u $DOCKER_USER -p $DOCKER_TOKEN && \ docker manifest push containrrr/watchtower:$(echo $TAG | sed 's/^v*//') && \ docker manifest push containrrr/watchtower:latest - name: Push manifests to GitHub Container Registry env: DOCKER_USER: ${{ secrets.BOT_USERNAME }} DOCKER_TOKEN: ${{ secrets.BOT_GHCR_PAT }} run: | docker login -u $DOCKER_USER -p $DOCKER_TOKEN ghcr.io && \ docker manifest push ghcr.io/containrrr/watchtower:$(echo $TAG | sed 's/^v*//') && \ docker manifest push ghcr.io/containrrr/watchtower:latest renew-docs: name: Refresh pkg.go.dev needs: build runs-on: ubuntu-latest steps: - name: Pull new module version uses: andrewslotin/go-proxy-pull-action@50fea06a976087614babb9508e5c528b464f4645 #master@2022-10-14 ================================================ FILE: .gitignore ================================================ watchtower watchtower.exe vendor .glide dist .idea .DS_Store /site coverage.out *.coverprofile docs/assets/wasm_exec.js docs/assets/*.wasm ================================================ FILE: CONTRIBUTING.md ================================================ ## Prerequisites To contribute code changes to this project you will need the following development kits. * [Go](https://golang.org/doc/install) * [Docker](https://docs.docker.com/engine/installation/) As watchtower utilizes go modules for vendor locking, you'll need at least Go 1.11. You can check your current version of the go language as follows: ```bash ~ $ go version go version go1.12.1 darwin/amd64 ``` ## Checking out the code Do not place your code in the go source path. ```bash git clone git@github.com:/watchtower.git cd watchtower ``` ## Building and testing watchtower is a go application and is built with go commands. The following commands assume that you are at the root level of your repo. ```bash go build # compiles and packages an executable binary, watchtower go test ./... -v # runs tests with verbose output ./watchtower # runs the application (outside of a container) ``` If you dont have it enabled, you'll either have to prefix each command with `GO111MODULE=on` or run `export GO111MODULE=on` before running the commands. [You can read more about modules here.](https://github.com/golang/go/wiki/Modules) To build a Watchtower image of your own, use the self-contained Dockerfiles. As the main Dockerfile, they can be found in `dockerfiles/`: - `dockerfiles/Dockerfile.dev-self-contained` will build an image based on your current local Watchtower files. - `dockerfiles/Dockerfile.self-contained` will build an image based on current Watchtower's repository on GitHub. e.g.: ```bash sudo docker build . -f dockerfiles/Dockerfile.dev-self-contained -t containrrr/watchtower # to build an image from local files ``` ================================================ FILE: LICENSE.md ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2015 Watchtower contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. Watchtower contains code that is licensed under a BSD-license: - Copyright (c) 2009 The Go Authors. All rights reserved. For details see https://golang.org/LICENSE ================================================ FILE: README.md ================================================
### ⚠️ This project is no longer maintained See https://github.com/containrrr/watchtower/discussions/2135 for details. --- # Watchtower A process for automating Docker container base image updates.

[![Circle CI](https://circleci.com/gh/containrrr/watchtower.svg?style=shield)](https://circleci.com/gh/containrrr/watchtower) [![codecov](https://codecov.io/gh/containrrr/watchtower/branch/main/graph/badge.svg)](https://codecov.io/gh/containrrr/watchtower) [![GoDoc](https://godoc.org/github.com/containrrr/watchtower?status.svg)](https://godoc.org/github.com/containrrr/watchtower) [![Go Report Card](https://goreportcard.com/badge/github.com/containrrr/watchtower)](https://goreportcard.com/report/github.com/containrrr/watchtower) [![latest version](https://img.shields.io/github/tag/containrrr/watchtower.svg)](https://github.com/containrrr/watchtower/releases) [![Apache-2.0 License](https://img.shields.io/github/license/containrrr/watchtower.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/containrrr/watchtower/dashboard?utm_source=github.com&utm_medium=referral&utm_content=containrrr/watchtower&utm_campaign=Badge_Grade) [![All Contributors](https://img.shields.io/github/all-contributors/containrrr/watchtower)](#contributors) [![Pulls from DockerHub](https://img.shields.io/docker/pulls/containrrr/watchtower.svg)](https://hub.docker.com/r/containrrr/watchtower)
## Quick Start With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker Hub or your own image registry. Watchtower will pull down your new image, gracefully shut down your existing container and restart it with the same options that were used when it was deployed initially. Run the watchtower container with the following command: ``` $ docker run --detach \ --name watchtower \ --volume /var/run/docker.sock:/var/run/docker.sock \ containrrr/watchtower ``` Watchtower is intended to be used in homelabs, media centers, local dev environments, and similar. We do **not** recommend using Watchtower in a commercial or production environment. If that is you, you should be looking into using Kubernetes. If that feels like too big a step for you, please look into solutions like [MicroK8s](https://microk8s.io/) and [k3s](https://k3s.io/) that take away a lot of the toil of running a Kubernetes cluster. ## Documentation The full documentation is available at https://containrrr.dev/watchtower. ## Contributors Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
nils måsén
nils måsén

💻 📖 🚧 👀
Simon Aronsson
Simon Aronsson

💻 📖 🚧 👀
James
James

⚠️ 🤔
Florian
Florian

👀 📖
Brian DeHamer
Brian DeHamer

💻 🚧
Ross Cadogan
Ross Cadogan

💻
stffabi
stffabi

💻 🚧
Austin
Austin

📖
David Gardner
David Gardner

👀 📖
Tanguy ⧓ Herrmann
Tanguy ⧓ Herrmann

💻
Rodrigo Damazio Bovendorp
Rodrigo Damazio Bovendorp

💻 📖
Ryan Kuba
Ryan Kuba

🚇
cnrmck
cnrmck

📖
Harry Walter
Harry Walter

💻
Robotex
Robotex

📖
Gerald Pape
Gerald Pape

📖
fomk
fomk

💻
Sven Gottwald
Sven Gottwald

🚇
techknowlogick
techknowlogick

💻
waja
waja

📖
Scott Albertson
Scott Albertson

📖
Jason Huddleston
Jason Huddleston

📖
Napster
Napster

💻
Maxim
Maxim

💻 📖
Max Schmitt
Max Schmitt

📖
cron410
cron410

📖
Paulo Henrique
Paulo Henrique

📖
Kaleb Elwert
Kaleb Elwert

📖
Bill Butler
Bill Butler

📖
Mario Tacke
Mario Tacke

💻
Mark Woodbridge
Mark Woodbridge

💻
Ansem93
Ansem93

📖
Luka Peschke
Luka Peschke

💻 📖
Zois Pagoulatos
Zois Pagoulatos

💻 👀 🚧
Alexandre Menif
Alexandre Menif

💻
Andrey
Andrey

📖
Armando Lüscher
Armando Lüscher

📖
Ryan Budke
Ryan Budke

📖
Kaloyan Raev
Kaloyan Raev

💻 ⚠️
sixth
sixth

📖
Gina Häußge
Gina Häußge

💻
Max H.
Max H.

💻
Jungkook Park
Jungkook Park

📖
Jan Kristof Nidzwetzki
Jan Kristof Nidzwetzki

📖
lukas
lukas

💻
Ameya Shenoy
Ameya Shenoy

💻
Raymon de Looff
Raymon de Looff

💻
John Clayton
John Clayton

💻
Germs2004
Germs2004

📖
Lukas Willburger
Lukas Willburger

💻
Oliver Cervera
Oliver Cervera

📖
Victor Moura
Victor Moura

⚠️ 💻 📖
Maximilian Brandau
Maximilian Brandau

💻 ⚠️
Andrew
Andrew

📖
sixcorners
sixcorners

📖
Arne Jørgensen
Arne Jørgensen

⚠️ 👀
PatSki123
PatSki123

📖
Valentine Zavadsky
Valentine Zavadsky

💻 📖 ⚠️
Alexander Voronin
Alexander Voronin

💻 🐛
Oliver Mueller
Oliver Mueller

📖
Sebastiaan Tammer
Sebastiaan Tammer

💻
miosame
miosame

📖
Andrew Metzger
Andrew Metzger

🐛 💡
Pierre Grimaud
Pierre Grimaud

📖
Matt Doran
Matt Doran

📖
MihailITPlace
MihailITPlace

💻
bugficks
bugficks

💻 📖
Michael
Michael

💻
D. Domig
D. Domig

📖
Ben Osheroff
Ben Osheroff

💻
David H.
David H.

💻
Chander Ganesan
Chander Ganesan

📖
yrien30
yrien30

💻
ksurl
ksurl

📖 💻 🚇
rg9400
rg9400

💻
Turtle Kalus
Turtle Kalus

💻
Srihari Thalla
Srihari Thalla

📖
Thomas Gaudin
Thomas Gaudin

📖
hydrargyrum
hydrargyrum

📖
Reinout van Rees
Reinout van Rees

📖
DasSkelett
DasSkelett

💻
zenjabba
zenjabba

📖
Dan Quan
Dan Quan

📖
modem7
modem7

📖
Igor Zibarev
Igor Zibarev

💻
Patrice
Patrice

💻
James White
James White

📖
Dirk Kok
Dirk Kok

💻
EDIflyer
EDIflyer

📖
Jauder Ho
Jauder Ho

💻
Tamal Das
Tamal Das

📖
guangwu
guangwu

📖
Florian Hübner
Florian Hübner

📖 💻

Andrii Bratanin

📖
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Security updates will always only be applied to the latest version of Watchtower. As the software by default is set to auto-update if you use the `latest` tag, you will get these security updates automatically as soon as they are released. ## Reporting a Vulnerability Critical vulnerabilities that might open up for external attacks are best reported directly either to simme@arcticbit.se or nils@piksel.se. We'll always try to get back to you as swiftly as possible, but keep in mind that since this is a community project, we can't really leave any guarantees about the speed. Non-critical vulnerabilities may be reported as regular GitHub issues. ================================================ FILE: build.sh ================================================ #!/bin/bash BINFILE=watchtower if [ -n "$MSYSTEM" ]; then BINFILE=watchtower.exe fi VERSION=$(git describe --tags) echo "Building $VERSION..." go build -o $BINFILE -ldflags "-X github.com/containrrr/watchtower/internal/meta.Version=$VERSION" ================================================ FILE: cmd/notify-upgrade.go ================================================ // Package cmd contains the watchtower (sub-)commands package cmd import ( "fmt" "os" "os/signal" "strings" "syscall" "time" "github.com/containrrr/watchtower/internal/flags" "github.com/containrrr/watchtower/pkg/container" "github.com/containrrr/watchtower/pkg/notifications" "github.com/spf13/cobra" ) var notifyUpgradeCommand = NewNotifyUpgradeCommand() // NewNotifyUpgradeCommand creates the notify upgrade command for watchtower func NewNotifyUpgradeCommand() *cobra.Command { return &cobra.Command{ Use: "notify-upgrade", Short: "Upgrade legacy notification configuration to shoutrrr URLs", Run: runNotifyUpgrade, } } func runNotifyUpgrade(cmd *cobra.Command, args []string) { if err := runNotifyUpgradeE(cmd, args); err != nil { logf("Notification upgrade failed: %v", err) } } func runNotifyUpgradeE(cmd *cobra.Command, _ []string) error { f := cmd.Flags() flags.ProcessFlagAliases(f) notifier = notifications.NewNotifier(cmd) urls := notifier.GetURLs() logf("Found notification configurations for: %v", strings.Join(notifier.GetNames(), ", ")) outFile, err := os.CreateTemp("/", "watchtower-notif-urls-*") if err != nil { return fmt.Errorf("failed to create output file: %v", err) } logf("Writing notification URLs to %v", outFile.Name()) logf("") sb := strings.Builder{} sb.WriteString("WATCHTOWER_NOTIFICATION_URL=") for i, u := range urls { if i != 0 { sb.WriteRune(' ') } sb.WriteString(u) } _, err = fmt.Fprint(outFile, sb.String()) tryOrLog(err, "Failed to write to output file") tryOrLog(outFile.Sync(), "Failed to sync output file") tryOrLog(outFile.Close(), "Failed to close output file") containerID := "" cid, err := container.GetRunningContainerID() tryOrLog(err, "Failed to get running container ID") if cid != "" { containerID = cid.ShortID() } logf("To get the environment file, use:") logf("cp %v:%v ./watchtower-notifications.env", containerID, outFile.Name()) logf("") logf("Note: This file will be removed in 5 minutes or when this container is stopped!") signalChannel := make(chan os.Signal, 1) time.AfterFunc(5*time.Minute, func() { signalChannel <- syscall.SIGALRM }) signal.Notify(signalChannel, os.Interrupt) signal.Notify(signalChannel, syscall.SIGTERM) switch <-signalChannel { case syscall.SIGALRM: logf("Timed out!") case os.Interrupt, syscall.SIGTERM: logf("Stopping...") default: } if err := os.Remove(outFile.Name()); err != nil { logf("Failed to remove file, it may still be present in the container image! Error: %v", err) } else { logf("Environment file has been removed.") } return nil } func tryOrLog(err error, message string) { if err != nil { logf("%v: %v\n", message, err) } } func logf(format string, v ...interface{}) { fmt.Fprintln(os.Stderr, fmt.Sprintf(format, v...)) } ================================================ FILE: cmd/root.go ================================================ package cmd import ( "errors" "math" "net/http" "os" "os/signal" "strconv" "strings" "syscall" "time" "github.com/containrrr/watchtower/internal/actions" "github.com/containrrr/watchtower/internal/flags" "github.com/containrrr/watchtower/internal/meta" "github.com/containrrr/watchtower/pkg/api" apiMetrics "github.com/containrrr/watchtower/pkg/api/metrics" "github.com/containrrr/watchtower/pkg/api/update" "github.com/containrrr/watchtower/pkg/container" "github.com/containrrr/watchtower/pkg/filters" "github.com/containrrr/watchtower/pkg/metrics" "github.com/containrrr/watchtower/pkg/notifications" t "github.com/containrrr/watchtower/pkg/types" "github.com/robfig/cron" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) var ( client container.Client scheduleSpec string cleanup bool noRestart bool noPull bool monitorOnly bool enableLabel bool disableContainers []string notifier t.Notifier timeout time.Duration lifecycleHooks bool rollingRestart bool scope string labelPrecedence bool ) var rootCmd = NewRootCommand() // NewRootCommand creates the root command for watchtower func NewRootCommand() *cobra.Command { return &cobra.Command{ Use: "watchtower", Short: "Automatically updates running Docker containers", Long: ` Watchtower automatically updates running Docker containers whenever a new image is released. More information available at https://github.com/containrrr/watchtower/. `, Run: Run, PreRun: PreRun, Args: cobra.ArbitraryArgs, } } func init() { flags.SetDefaults() flags.RegisterDockerFlags(rootCmd) flags.RegisterSystemFlags(rootCmd) flags.RegisterNotificationFlags(rootCmd) } // Execute the root func and exit in case of errors func Execute() { rootCmd.AddCommand(notifyUpgradeCommand) if err := rootCmd.Execute(); err != nil { log.Fatal(err) } } // PreRun is a lifecycle hook that runs before the command is executed. func PreRun(cmd *cobra.Command, _ []string) { f := cmd.PersistentFlags() flags.ProcessFlagAliases(f) if err := flags.SetupLogging(f); err != nil { log.Fatalf("Failed to initialize logging: %s", err.Error()) } scheduleSpec, _ = f.GetString("schedule") flags.GetSecretsFromFiles(cmd) cleanup, noRestart, monitorOnly, timeout = flags.ReadFlags(cmd) if timeout < 0 { log.Fatal("Please specify a positive value for timeout value.") } enableLabel, _ = f.GetBool("label-enable") disableContainers, _ = f.GetStringSlice("disable-containers") lifecycleHooks, _ = f.GetBool("enable-lifecycle-hooks") rollingRestart, _ = f.GetBool("rolling-restart") scope, _ = f.GetString("scope") labelPrecedence, _ = f.GetBool("label-take-precedence") if scope != "" { log.Debugf(`Using scope %q`, scope) } // configure environment vars for client err := flags.EnvConfig(cmd) if err != nil { log.Fatal(err) } noPull, _ = f.GetBool("no-pull") includeStopped, _ := f.GetBool("include-stopped") includeRestarting, _ := f.GetBool("include-restarting") reviveStopped, _ := f.GetBool("revive-stopped") removeVolumes, _ := f.GetBool("remove-volumes") warnOnHeadPullFailed, _ := f.GetString("warn-on-head-failure") if monitorOnly && noPull { log.Warn("Using `WATCHTOWER_NO_PULL` and `WATCHTOWER_MONITOR_ONLY` simultaneously might lead to no action being taken at all. If this is intentional, you may safely ignore this message.") } client = container.NewClient(container.ClientOptions{ IncludeStopped: includeStopped, ReviveStopped: reviveStopped, RemoveVolumes: removeVolumes, IncludeRestarting: includeRestarting, WarnOnHeadFailed: container.WarningStrategy(warnOnHeadPullFailed), }) notifier = notifications.NewNotifier(cmd) notifier.AddLogHook() } // Run is the main execution flow of the command func Run(c *cobra.Command, names []string) { filter, filterDesc := filters.BuildFilter(names, disableContainers, enableLabel, scope) runOnce, _ := c.PersistentFlags().GetBool("run-once") enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update") enableMetricsAPI, _ := c.PersistentFlags().GetBool("http-api-metrics") unblockHTTPAPI, _ := c.PersistentFlags().GetBool("http-api-periodic-polls") apiToken, _ := c.PersistentFlags().GetString("http-api-token") healthCheck, _ := c.PersistentFlags().GetBool("health-check") if healthCheck { // health check should not have pid 1 if os.Getpid() == 1 { time.Sleep(1 * time.Second) log.Fatal("The health check flag should never be passed to the main watchtower container process") } os.Exit(0) } if rollingRestart && monitorOnly { log.Fatal("Rolling restarts is not compatible with the global monitor only flag") } awaitDockerClient() if err := actions.CheckForSanity(client, filter, rollingRestart); err != nil { logNotifyExit(err) } if runOnce { writeStartupMessage(c, time.Time{}, filterDesc) runUpdatesWithNotifications(filter) notifier.Close() os.Exit(0) return } if err := actions.CheckForMultipleWatchtowerInstances(client, cleanup, scope); err != nil { logNotifyExit(err) } // The lock is shared between the scheduler and the HTTP API. It only allows one update to run at a time. updateLock := make(chan bool, 1) updateLock <- true httpAPI := api.New(apiToken) if enableUpdateAPI { updateHandler := update.New(func(images []string) { metric := runUpdatesWithNotifications(filters.FilterByImage(images, filter)) metrics.RegisterScan(metric) }, updateLock) httpAPI.RegisterFunc(updateHandler.Path, updateHandler.Handle) // If polling isn't enabled the scheduler is never started, and // we need to trigger the startup messages manually. if !unblockHTTPAPI { writeStartupMessage(c, time.Time{}, filterDesc) } } if enableMetricsAPI { metricsHandler := apiMetrics.New() httpAPI.RegisterHandler(metricsHandler.Path, metricsHandler.Handle) } if err := httpAPI.Start(enableUpdateAPI && !unblockHTTPAPI); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Error("failed to start API", err) } if err := runUpgradesOnSchedule(c, filter, filterDesc, updateLock); err != nil { log.Error(err) } os.Exit(1) } func logNotifyExit(err error) { log.Error(err) notifier.Close() os.Exit(1) } func awaitDockerClient() { log.Debug("Sleeping for a second to ensure the docker api client has been properly initialized.") time.Sleep(1 * time.Second) } func formatDuration(d time.Duration) string { sb := strings.Builder{} hours := int64(d.Hours()) minutes := int64(math.Mod(d.Minutes(), 60)) seconds := int64(math.Mod(d.Seconds(), 60)) if hours == 1 { sb.WriteString("1 hour") } else if hours != 0 { sb.WriteString(strconv.FormatInt(hours, 10)) sb.WriteString(" hours") } if hours != 0 && (seconds != 0 || minutes != 0) { sb.WriteString(", ") } if minutes == 1 { sb.WriteString("1 minute") } else if minutes != 0 { sb.WriteString(strconv.FormatInt(minutes, 10)) sb.WriteString(" minutes") } if minutes != 0 && (seconds != 0) { sb.WriteString(", ") } if seconds == 1 { sb.WriteString("1 second") } else if seconds != 0 || (hours == 0 && minutes == 0) { sb.WriteString(strconv.FormatInt(seconds, 10)) sb.WriteString(" seconds") } return sb.String() } func writeStartupMessage(c *cobra.Command, sched time.Time, filtering string) { noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message") enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update") var startupLog *log.Entry if noStartupMessage { startupLog = notifications.LocalLog } else { startupLog = log.NewEntry(log.StandardLogger()) // Batch up startup messages to send them as a single notification notifier.StartNotification() } startupLog.Info("Watchtower ", meta.Version) notifierNames := notifier.GetNames() if len(notifierNames) > 0 { startupLog.Info("Using notifications: " + strings.Join(notifierNames, ", ")) } else { startupLog.Info("Using no notifications") } startupLog.Info(filtering) if !sched.IsZero() { until := formatDuration(time.Until(sched)) startupLog.Info("Scheduling first run: " + sched.Format("2006-01-02 15:04:05 -0700 MST")) startupLog.Info("Note that the first check will be performed in " + until) } else if runOnce, _ := c.PersistentFlags().GetBool("run-once"); runOnce { startupLog.Info("Running a one time update.") } else { startupLog.Info("Periodic runs are not enabled.") } if enableUpdateAPI { // TODO: make listen port configurable startupLog.Info("The HTTP API is enabled at :8080.") } if !noStartupMessage { // Send the queued up startup messages, not including the trace warning below (to make sure it's noticed) notifier.SendNotification(nil) } if log.IsLevelEnabled(log.TraceLevel) { startupLog.Warn("Trace level enabled: log will include sensitive information as credentials and tokens") } } func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter, filtering string, lock chan bool) error { if lock == nil { lock = make(chan bool, 1) lock <- true } scheduler := cron.New() err := scheduler.AddFunc( scheduleSpec, func() { select { case v := <-lock: defer func() { lock <- v }() metric := runUpdatesWithNotifications(filter) metrics.RegisterScan(metric) default: // Update was skipped metrics.RegisterScan(nil) log.Debug("Skipped another update already running.") } nextRuns := scheduler.Entries() if len(nextRuns) > 0 { log.Debug("Scheduled next run: " + nextRuns[0].Next.String()) } }) if err != nil { return err } writeStartupMessage(c, scheduler.Entries()[0].Schedule.Next(time.Now()), filtering) scheduler.Start() // Graceful shut-down on SIGINT/SIGTERM interrupt := make(chan os.Signal, 1) signal.Notify(interrupt, os.Interrupt) signal.Notify(interrupt, syscall.SIGTERM) <-interrupt scheduler.Stop() log.Info("Waiting for running update to be finished...") <-lock return nil } func runUpdatesWithNotifications(filter t.Filter) *metrics.Metric { notifier.StartNotification() updateParams := t.UpdateParams{ Filter: filter, Cleanup: cleanup, NoRestart: noRestart, Timeout: timeout, MonitorOnly: monitorOnly, LifecycleHooks: lifecycleHooks, RollingRestart: rollingRestart, LabelPrecedence: labelPrecedence, NoPull: noPull, } result, err := actions.Update(client, updateParams) if err != nil { log.Error(err) } notifier.SendNotification(result) metricResults := metrics.NewMetric(result) notifications.LocalLog.WithFields(log.Fields{ "Scanned": metricResults.Scanned, "Updated": metricResults.Updated, "Failed": metricResults.Failed, }).Info("Session done") return metricResults } ================================================ FILE: code_of_conduct.md ================================================ ### Containrrr Community Code of Conduct Please refer to out [Containrrr Community Code of Conduct](https://github.com/containrrr/.github/blob/master/CODE_OF_CONDUCT.md) ================================================ FILE: docker-compose.yml ================================================ version: '3.7' services: watchtower: container_name: watchtower build: context: ./ dockerfile: dockerfiles/Dockerfile.dev-self-contained volumes: - /var/run/docker.sock:/var/run/docker.sock:ro ports: - 8080:8080 command: --interval 10 --http-api-metrics --http-api-token demotoken --debug prometheus grafana parent child prometheus: container_name: prometheus image: prom/prometheus volumes: - ./prometheus/:/etc/prometheus/ - prometheus:/prometheus/ ports: - 9090:9090 grafana: container_name: grafana image: grafana/grafana ports: - 3000:3000 environment: GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-simple-json-datasource volumes: - grafana:/var/lib/grafana - ./grafana:/etc/grafana/provisioning parent: image: nginx container_name: parent child: image: nginx:alpine labels: com.centurylinklabs.watchtower.depends-on: parent container_name: child volumes: prometheus: {} grafana: {} ================================================ FILE: dockerfiles/Dockerfile ================================================ FROM --platform=$BUILDPLATFORM alpine:3.19.0 as alpine RUN apk add --no-cache \ ca-certificates \ tzdata FROM scratch LABEL "com.centurylinklabs.watchtower"="true" COPY --from=alpine \ /etc/ssl/certs/ca-certificates.crt \ /etc/ssl/certs/ca-certificates.crt COPY --from=alpine \ /usr/share/zoneinfo \ /usr/share/zoneinfo EXPOSE 8080 COPY watchtower / HEALTHCHECK CMD [ "/watchtower", "--health-check"] ENTRYPOINT ["/watchtower"] ================================================ FILE: dockerfiles/Dockerfile.dev-self-contained ================================================ # # Builder # FROM golang:alpine as builder # use version (for example "v0.3.3") or "main" ARG WATCHTOWER_VERSION=main # Pre download required modules to avoid redownloading at each build thanks to docker layer caching. # Copying go.mod and go.sum ensure to invalid the layer/build cache if there is a change in module requirement WORKDIR /watchtower COPY go.mod . COPY go.sum . RUN go mod download RUN apk add --no-cache \ alpine-sdk \ ca-certificates \ git \ tzdata COPY . /watchtower RUN \ cd /watchtower && \ \ GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/containrrr/watchtower/internal/meta.Version=$(git describe --tags)" . && \ GO111MODULE=on go test ./... -v # # watchtower # FROM scratch LABEL "com.centurylinklabs.watchtower"="true" # copy files from other container COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=builder /watchtower/watchtower /watchtower HEALTHCHECK CMD [ "/watchtower", "--health-check"] ENTRYPOINT ["/watchtower"] ================================================ FILE: dockerfiles/Dockerfile.self-contained ================================================ # # Builder # FROM golang:alpine as builder # use version (for example "v0.3.3") or "main" ARG WATCHTOWER_VERSION=main RUN apk add --no-cache \ alpine-sdk \ ca-certificates \ git \ tzdata RUN git clone --branch "${WATCHTOWER_VERSION}" https://github.com/containrrr/watchtower.git RUN \ cd watchtower && \ \ GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/containrrr/watchtower/internal/meta.Version=$(git describe --tags)" . && \ GO111MODULE=on go test ./... -v # # watchtower # FROM scratch LABEL "com.centurylinklabs.watchtower"="true" # copy files from other container COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=builder /go/watchtower/watchtower /watchtower HEALTHCHECK CMD [ "/watchtower", "--health-check"] ENTRYPOINT ["/watchtower"] ================================================ FILE: dockerfiles/container-networking/docker-compose.yml ================================================ services: producer: image: qmcgaw/gluetun:v3.35.0 cap_add: - NET_ADMIN environment: - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER} - OPENVPN_USER=${OPENVPN_USER} - OPENVPN_PASSWORD=${OPENVPN_PASSWORD} - SERVER_COUNTRIES=${SERVER_COUNTRIES} consumer: depends_on: - producer image: nginx:1.25.1 network_mode: "service:producer" labels: - "com.centurylinklabs.watchtower.depends-on=/wt-contnet-producer-1" ================================================ FILE: docs/arguments.md ================================================ By default, watchtower will monitor all containers running within the Docker daemon to which it is pointed (in most cases this will be the local Docker daemon, but you can override it with the `--host` option described in the next section). However, you can restrict watchtower to monitoring a subset of the running containers by specifying the container names as arguments when launching watchtower. ```bash $ docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ containrrr/watchtower \ nginx redis ``` In the example above, watchtower will only monitor the containers named "nginx" and "redis" for updates -- all of the other running containers will be ignored. If you do not want watchtower to run as a daemon you can pass the `--run-once` flag and remove the watchtower container after its execution. ```bash $ docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ containrrr/watchtower \ --run-once \ nginx redis ``` In the example above, watchtower will execute an upgrade attempt on the containers named "nginx" and "redis". Using this mode will enable debugging output showing all actions performed, as usage is intended for interactive users. Once the attempt is completed, the container will exit and remove itself due to the `--rm` flag. When no arguments are specified, watchtower will monitor all running containers. ## Secrets/Files Some arguments can also reference a file, in which case the contents of the file are used as the value. This can be used to avoid putting secrets in the configuration file or command line. The following arguments are currently supported (including their corresponding `WATCHTOWER_` environment variables): - `notification-url` - `notification-email-server-password` - `notification-slack-hook-url` - `notification-msteams-hook` - `notification-gotify-token` - `http-api-token` ### Example docker-compose usage ```yaml secrets: access_token: file: access_token services: watchtower: secrets: - access_token environment: - WATCHTOWER_HTTP_API_TOKEN=/run/secrets/access_token ``` ## Help Shows documentation about the supported flags. ```text Argument: --help Environment Variable: N/A Type: N/A Default: N/A ``` ## Time Zone Sets the time zone to be used by WatchTower's logs and the optional Cron scheduling argument (--schedule). If this environment variable is not set, Watchtower will use the default time zone: UTC. To find out the right value, see [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), find your location and use the value in _TZ Database Name_, e.g _Europe/Rome_. The timezone can alternatively be set by volume mounting your hosts /etc/localtime file. `-v /etc/localtime:/etc/localtime:ro` ```text Argument: N/A Environment Variable: TZ Type: String Default: "UTC" ``` ## Cleanup Removes old images after updating. When this flag is specified, watchtower will remove the old image after restarting a container with a new image. Use this option to prevent the accumulation of orphaned images on your system as containers are updated. ```text Argument: --cleanup Environment Variable: WATCHTOWER_CLEANUP Type: Boolean Default: false ``` ## Remove anonymous volumes Removes anonymous volumes after updating. When this flag is specified, watchtower will remove all anonymous volumes from the container before restarting with a new image. Named volumes will not be removed! ```text Argument: --remove-volumes Environment Variable: WATCHTOWER_REMOVE_VOLUMES Type: Boolean Default: false ``` ## Debug Enable debug mode with verbose logging. !!! note "Notes" Alias for `--log-level debug`. See [Maximum log level](#maximum-log-level). Does _not_ take an argument when used as an argument. Using `--debug true` will **not** work. ```text Argument: --debug, -d Environment Variable: WATCHTOWER_DEBUG Type: Boolean Default: false ``` ## Trace Enable trace mode with very verbose logging. Caution: exposes credentials! !!! note "Notes" Alias for `--log-level trace`. See [Maximum log level](#maximum-log-level). Does _not_ take an argument when used as an argument. Using `--trace true` will **not** work. ```text Argument: --trace Environment Variable: WATCHTOWER_TRACE Type: Boolean Default: false ``` ## Maximum log level The maximum log level that will be written to STDERR (shown in `docker log` when used in a container). ```text Argument: --log-level Environment Variable: WATCHTOWER_LOG_LEVEL Possible values: panic, fatal, error, warn, info, debug or trace Default: info ``` ## Logging format Sets what logging format to use for console output. ```text Argument: --log-format, -l Environment Variable: WATCHTOWER_LOG_FORMAT Possible values: Auto, LogFmt, Pretty or JSON Default: Auto ``` ## ANSI colors Disable ANSI color escape codes in log output. ```text Argument: --no-color Environment Variable: NO_COLOR Type: Boolean Default: false ``` ## Docker host Docker daemon socket to connect to. Can be pointed at a remote Docker host by specifying a TCP endpoint as "tcp://hostname:port". ```text Argument: --host, -H Environment Variable: DOCKER_HOST Type: String Default: "unix:///var/run/docker.sock" ``` ## Docker API version The API version to use by the Docker client for connecting to the Docker daemon. The minimum supported version is 1.24. ```text Argument: --api-version, -a Environment Variable: DOCKER_API_VERSION Type: String Default: "1.24" ``` ## Include restarting Will also include restarting containers. ```text Argument: --include-restarting Environment Variable: WATCHTOWER_INCLUDE_RESTARTING Type: Boolean Default: false ``` ## Include stopped Will also include created and exited containers. ```text Argument: --include-stopped, -S Environment Variable: WATCHTOWER_INCLUDE_STOPPED Type: Boolean Default: false ``` ## Revive stopped Start any stopped containers that have had their image updated. This argument is only usable with the `--include-stopped` argument. ```text Argument: --revive-stopped Environment Variable: WATCHTOWER_REVIVE_STOPPED Type: Boolean Default: false ``` ## Poll interval Poll interval (in seconds). This value controls how frequently watchtower will poll for new images. Either `--schedule` or a poll interval can be defined, but not both. ```text Argument: --interval, -i Environment Variable: WATCHTOWER_POLL_INTERVAL Type: Integer Default: 86400 (24 hours) ``` ## Filter by enable label Monitor and update containers that have a `com.centurylinklabs.watchtower.enable` label set to true. ```text Argument: --label-enable Environment Variable: WATCHTOWER_LABEL_ENABLE Type: Boolean Default: false ``` ## Filter by disable label __Do not__ Monitor and update containers that have `com.centurylinklabs.watchtower.enable` label set to false and no `--label-enable` argument is passed. Note that only one or the other (targeting by enable label) can be used at the same time to target containers. ## Filter by disabling specific container names Monitor and update containers whose names are not in a given set of names. This can be used to exclude specific containers, when setting labels is not an option. The listed containers will be excluded even if they have the enable filter set to true. ```text Argument: --disable-containers, -x Environment Variable: WATCHTOWER_DISABLE_CONTAINERS Type: Comma- or space-separated string list Default: "" ``` ## Without updating containers Will only monitor for new images, send notifications and invoke the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/), but will __not__ update the containers. !!! note Due to Docker API limitations the latest image will still be pulled from the registry. The HEAD digest checks allows watchtower to skip pulling when there are no changes, but to know _what_ has changed it will still do a pull whenever the repository digest doesn't match the local image digest. ```text Argument: --monitor-only Environment Variable: WATCHTOWER_MONITOR_ONLY Type: Boolean Default: false ``` Note that monitor-only can also be specified on a per-container basis with the `com.centurylinklabs.watchtower.monitor-only` label set on those containers. See [With label taking precedence over arguments](#With-label-taking-precedence-over-arguments) for behavior when both argument and label are set ## With label taking precedence over arguments By default, arguments will take precedence over labels. This means that if you set `WATCHTOWER_MONITOR_ONLY` to true or use `--monitor-only`, a container with `com.centurylinklabs.watchtower.monitor-only` set to false will not be updated. If you set `WATCHTOWER_LABEL_TAKE_PRECEDENCE` to true or use `--label-take-precedence`, then the container will also be updated. This also apply to the no pull option. if you set `WATCHTOWER_NO_PULL` to true or use `--no-pull`, a container with `com.centurylinklabs.watchtower.no-pull` set to false will not pull the new image. If you set `WATCHTOWER_LABEL_TAKE_PRECEDENCE` to true or use `--label-take-precedence`, then the container will pull image ```text Argument: --label-take-precedence Environment Variable: WATCHTOWER_LABEL_TAKE_PRECEDENCE Type: Boolean Default: false ``` ## Without restarting containers Do not restart containers after updating. This option can be useful when the start of the containers is managed by an external system such as systemd. ```text Argument: --no-restart Environment Variable: WATCHTOWER_NO_RESTART Type: Boolean Default: false ``` ## Without pulling new images Do not pull new images. When this flag is specified, watchtower will not attempt to pull new images from the registry. Instead it will only monitor the local image cache for changes. Use this option if you are building new images directly on the Docker host without pushing them to a registry. ```text Argument: --no-pull Environment Variable: WATCHTOWER_NO_PULL Type: Boolean Default: false ``` Note that no-pull can also be specified on a per-container basis with the `com.centurylinklabs.watchtower.no-pull` label set on those containers. See [With label taking precedence over arguments](#With-label-taking-precedence-over-arguments) for behavior when both argument and label are set ## Without sending a startup message Do not send a message after watchtower started. Otherwise there will be an info-level notification. ```text Argument: --no-startup-message Environment Variable: WATCHTOWER_NO_STARTUP_MESSAGE Type: Boolean Default: false ``` ## Run once Run an update attempt against a container name list one time immediately and exit. ```text Argument: --run-once, -R Environment Variable: WATCHTOWER_RUN_ONCE Type: Boolean Default: false ``` ## HTTP API Mode Runs Watchtower in HTTP API mode, only allowing image updates to be triggered by an HTTP request. For details see [HTTP API](https://containrrr.dev/watchtower/http-api-mode). ```text Argument: --http-api-update Environment Variable: WATCHTOWER_HTTP_API_UPDATE Type: Boolean Default: false ``` ## HTTP API Token Sets an authentication token to HTTP API requests. Can also reference a file, in which case the contents of the file are used. ```text Argument: --http-api-token Environment Variable: WATCHTOWER_HTTP_API_TOKEN Type: String Default: - ``` ## HTTP API periodic polls Keep running periodic updates if the HTTP API mode is enabled, otherwise the HTTP API would prevent periodic polls. ```text Argument: --http-api-periodic-polls Environment Variable: WATCHTOWER_HTTP_API_PERIODIC_POLLS Type: Boolean Default: false ``` ## Filter by scope Update containers that have a `com.centurylinklabs.watchtower.scope` label set with the same value as the given argument. This enables [running multiple instances](https://containrrr.dev/watchtower/running-multiple-instances). !!! note "Filter by lack of scope" If you want other instances of watchtower to ignore the scoped containers, set this argument to `none`. When omitted, watchtower will update all containers regardless of scope. ```text Argument: --scope Environment Variable: WATCHTOWER_SCOPE Type: String Default: - ``` ## HTTP API Metrics Enables a metrics endpoint, exposing prometheus metrics via HTTP. See [Metrics](metrics.md) for details. ```text Argument: --http-api-metrics Environment Variable: WATCHTOWER_HTTP_API_METRICS Type: Boolean Default: false ``` ## Scheduling [Cron expression](https://pkg.go.dev/github.com/robfig/cron@v1.2.0?tab=doc#hdr-CRON_Expression_Format) in 6 fields (rather than the traditional 5) which defines when and how often to check for new images. Either `--interval` or the schedule expression can be defined, but not both. An example: `--schedule "0 0 4 * * *"` ```text Argument: --schedule, -s Environment Variable: WATCHTOWER_SCHEDULE Type: String Default: - ``` ## Rolling restart Restart one image at time instead of stopping and starting all at once. Useful in conjunction with lifecycle hooks to implement zero-downtime deploy. ```text Argument: --rolling-restart Environment Variable: WATCHTOWER_ROLLING_RESTART Type: Boolean Default: false ``` ## Wait until timeout Timeout before the container is forcefully stopped. When set, this option will change the default (`10s`) wait time to the given value. An example: `--stop-timeout 30s` will set the timeout to 30 seconds. ```text Argument: --stop-timeout Environment Variable: WATCHTOWER_TIMEOUT Type: Duration Default: 10s ``` ## TLS Verification Use TLS when connecting to the Docker socket and verify the server's certificate. See below for options used to configure notifications. ```text Argument: --tlsverify Environment Variable: DOCKER_TLS_VERIFY Type: Boolean Default: false ``` ## HEAD failure warnings When to warn about HEAD pull requests failing. Auto means that it will warn when the registry is known to handle the requests and may rate limit pull requests (mainly docker.io). ```text Argument: --warn-on-head-failure Environment Variable: WATCHTOWER_WARN_ON_HEAD_FAILURE Possible values: always, auto, never Default: auto ``` ## Health check Returns a success exit code to enable usage with docker `HEALTHCHECK`. This check is naive and only returns checks whether there is another process running inside the container, as it is the only known form of failure state for watchtowers container. !!! note "Only for HEALTHCHECK use" Never put this on the main container executable command line as it is only meant to be run from docker HEALTHCHECK. ```text Argument: --health-check ``` ## Programatic Output (porcelain) Writes the session results to STDOUT using a stable, machine-readable format (indicated by the argument VERSION). Alias for: ```text --notification-url logger:// --notification-log-stdout --notification-report --notification-template porcelain.VERSION.summary-no-log Argument: --porcelain, -P Environment Variable: WATCHTOWER_PORCELAIN Possible values: v1 Default: - ``` ================================================ FILE: docs/container-selection.md ================================================ By default, watchtower will watch all containers. However, sometimes only some containers should be updated. There are two options: - **Fully exclude**: You can choose to exclude containers entirely from being watched by watchtower. - **Monitor only**: In this mode, watchtower checks for container updates, sends notifications and invokes the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/) on the containers but does **not** perform the update. ## Full Exclude If you need to exclude some containers, set the _com.centurylinklabs.watchtower.enable_ label to `false`. For clarity this should be set **on the container(s)** you wish to be ignored, this is not set on watchtower. === "dockerfile" ```docker LABEL com.centurylinklabs.watchtower.enable="false" ``` === "docker run" ```bash docker run -d --label=com.centurylinklabs.watchtower.enable=false someimage ``` === "docker-compose" ``` yaml version: "3" services: someimage: container_name: someimage labels: - "com.centurylinklabs.watchtower.enable=false" ``` If instead you want to [only include containers with the enable label](https://containrrr.github.io/watchtower/arguments/#filter_by_enable_label), pass the `--label-enable` flag or the `WATCHTOWER_LABEL_ENABLE` environment variable on startup for watchtower and set the _com.centurylinklabs.watchtower.enable_ label with a value of `true` on the containers you want to watch. === "dockerfile" ```docker LABEL com.centurylinklabs.watchtower.enable="true" ``` === "docker run" ```bash docker run -d --label=com.centurylinklabs.watchtower.enable=true someimage ``` === "docker-compose" ``` yaml version: "3" services: someimage: container_name: someimage labels: - "com.centurylinklabs.watchtower.enable=true" ``` If you wish to create a monitoring scope, you will need to [run multiple instances and set a scope for each of them](https://containrrr.github.io/watchtower/running-multiple-instances). Watchtower filters running containers by testing them against each configured criteria. A container is monitored if all criteria are met. For example: - If a container's name is on the monitoring name list (not empty `--name` argument) but it is not enabled (_centurylinklabs.watchtower.enable=false_), it won't be monitored; - If a container's name is not on the monitoring name list (not empty `--name` argument), even if it is enabled (_centurylinklabs.watchtower.enable=true_ and `--label-enable` flag is set), it won't be monitored; ## Monitor Only Individual containers can be marked to only be monitored (without being updated). To do so, set the *com.centurylinklabs.watchtower.monitor-only* label to `true` on that container. ```docker LABEL com.centurylinklabs.watchtower.monitor-only="true" ``` Or, it can be specified as part of the `docker run` command line: ```bash docker run -d --label=com.centurylinklabs.watchtower.monitor-only=true someimage ``` When the label is specified on a container, watchtower treats that container exactly as if [`WATCHTOWER_MONITOR_ONLY`](https://containrrr.dev/watchtower/arguments/#without_updating_containers) was set, but the effect is limited to the individual container. ================================================ FILE: docs/http-api-mode.md ================================================ Watchtower provides an HTTP API mode that enables an HTTP endpoint that can be requested to trigger container updating. The current available endpoint list is: - `/v1/update` - triggers an update for all of the containers monitored by this Watchtower instance. --- To enable this mode, use the flag `--http-api-update`. For example, in a Docker Compose config file: ```yaml version: '3' services: app-monitored-by-watchtower: image: myapps/monitored-by-watchtower labels: - "com.centurylinklabs.watchtower.enable=true" watchtower: image: containrrr/watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock command: --debug --http-api-update environment: - WATCHTOWER_HTTP_API_TOKEN=mytoken labels: - "com.centurylinklabs.watchtower.enable=false" ports: - 8080:8080 ``` By default, enabling this mode prevents periodic polls (i.e. what is specified using `--interval` or `--schedule`). To run periodic updates regardless, pass `--http-api-periodic-polls`. Notice that there is an environment variable named WATCHTOWER_HTTP_API_TOKEN. To prevent external services from accidentally triggering image updates, all of the requests have to contain a "Token" field, valued as the token defined in WATCHTOWER_HTTP_API_TOKEN, in their headers. In this case, there is a port bind to the host machine, allowing to request localhost:8080 to reach Watchtower. The following `curl` command would trigger an image update: ```bash curl -H "Authorization: Bearer mytoken" localhost:8080/v1/update ``` --- In order to update only certain images, the image names can be provided as URL query parameters. The following `curl` command would trigger an update for the images `foo/bar` and `foo/baz`: ```bash curl -H "Authorization: Bearer mytoken" localhost:8080/v1/update?image=foo/bar,foo/baz ``` ================================================ FILE: docs/index.md ================================================

Logotype depicting a lighthouse

Watchtower

A container-based solution for automating Docker container base image updates.

Circle CI Codecov GoDoc Go Report Card latest version Apache-2.0 License Codacy Badge All Contributors Pulls from DockerHub

## Quick Start With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker Hub or your own image registry. Watchtower will pull down your new image, gracefully shut down your existing container and restart it with the same options that were used when it was deployed initially. Run the watchtower container with the following command: === "docker run" ```bash $ docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ containrrr/watchtower ``` === "docker-compose.yml" ```yaml version: "3" services: watchtower: image: containrrr/watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock ``` ================================================ FILE: docs/introduction.md ================================================ Watchtower is an application that will monitor your running Docker containers and watch for changes to the images that those containers were originally started from. If watchtower detects that an image has changed, it will automatically restart the container using the new image. With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker Hub or your own image registry. Watchtower will pull down your new image, gracefully shut down your existing container and restart it with the same options that were used when it was deployed initially. For example, let's say you were running watchtower along with an instance of _centurylink/wetty-cli_ image: ```text $ docker ps CONTAINER ID IMAGE STATUS PORTS NAMES 967848166a45 centurylink/wetty-cli Up 10 minutes 0.0.0.0:8080->3000/tcp wetty 6cc4d2a9d1a5 containrrr/watchtower Up 15 minutes watchtower ``` Every day watchtower will pull the latest _centurylink/wetty-cli_ image and compare it to the one that was used to run the "wetty" container. If it sees that the image has changed it will stop/remove the "wetty" container and then restart it using the new image and the same `docker run` options that were used to start the container initially (in this case, that would include the `-p 8080:3000` port mapping). ================================================ FILE: docs/lifecycle-hooks.md ================================================ ## Executing commands before and after updating !!! note These are shell commands executed with `sh`, and therefore require the container to provide the `sh` executable. > **DO NOTE**: If the container is not running then lifecycle hooks can not run and therefore > the update is executed without running any lifecycle hooks. It is possible to execute _pre/post\-check_ and _pre/post\-update_ commands **inside** every container updated by watchtower. - The _pre-check_ command is executed for each container prior to every update cycle. - The _pre-update_ command is executed before stopping the container when an update is about to start. - The _post-update_ command is executed after restarting the updated container - The _post-check_ command is executed for each container post every update cycle. This feature is disabled by default. To enable it, you need to set the option `--enable-lifecycle-hooks` on the command line, or set the environment variable `WATCHTOWER_LIFECYCLE_HOOKS` to `true`. ### Specifying update commands The commands are specified using docker container labels, the following are currently available: | Type | Docker Container Label | | ----------- | ------------------------------------------------------ | | Pre Check | `com.centurylinklabs.watchtower.lifecycle.pre-check` | | Pre Update | `com.centurylinklabs.watchtower.lifecycle.pre-update` | | Post Update | `com.centurylinklabs.watchtower.lifecycle.post-update` | | Post Check | `com.centurylinklabs.watchtower.lifecycle.post-check` | These labels can be declared as instructions in a Dockerfile (with some example .sh files) or be specified as part of the `docker run` command line: === "Dockerfile" ```docker LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh" LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" LABEL com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" LABEL com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh" ``` === "docker run" ```bash docker run -d \ --label=com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh" \ --label=com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" \ --label=com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" \ someimage --label=com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh" \ ``` ### Timeouts The timeout for all lifecycle commands is 60 seconds. After that, a timeout will occur, forcing Watchtower to continue the update loop. #### Pre- or Post-update timeouts For the `pre-update` or `post-update` lifecycle command, it is possible to override this timeout to allow the script to finish before forcefully killing it. This is done by adding the label `com.centurylinklabs.watchtower.lifecycle.pre-update-timeout` or post-update-timeout respectively followed by the timeout expressed in minutes. If the label value is explicitly set to `0`, the timeout will be disabled. ### Execution failure The failure of a command to execute, identified by an exit code different than 0 or 75 (EX_TEMPFAIL), will not prevent watchtower from updating the container. Only an error log statement containing the exit code will be reported. ================================================ FILE: docs/linked-containers.md ================================================ Watchtower will detect if there are links between any of the running containers and ensures that things are stopped/started in a way that won't break any of the links. If an update is detected for one of the dependencies in a group of linked containers, watchtower will stop and start all of the containers in the correct order so that the application comes back up correctly. For example, imagine you were running a _mysql_ container and a _wordpress_ container which had been linked to the _mysql_ container. If watchtower were to detect that the _mysql_ container required an update, it would first shut down the linked _wordpress_ container followed by the _mysql_ container. When restarting the containers it would handle _mysql_ first and then _wordpress_ to ensure that the link continued to work. If you want to override existing links, or if you are not using links, you can use special `com.centurylinklabs.watchtower.depends-on` label with dependent container names, separated by a comma. When you have a depending container that is using `network_mode: service:container` then watchtower will treat that container as an implicit link. ================================================ FILE: docs/metrics.md ================================================ !!! warning "Experimental feature" This feature was added in v1.0.4 and is still considered experimental. If you notice any strange behavior, please raise a ticket in the repository issues. Metrics can be used to track how Watchtower behaves over time. To use this feature, you have to set an [API token](arguments.md#http_api_token) and [enable the metrics API](arguments.md#http_api_metrics), as well as creating a port mapping for your container for port `8080`. The metrics API endpoint is `/v1/metrics`. ## Available Metrics | Name | Type | Description | | ------------------------------- | ------- | --------------------------------------------------------------------------- | | `watchtower_containers_scanned` | Gauge | Number of containers scanned for changes by watchtower during the last scan | | `watchtower_containers_updated` | Gauge | Number of containers updated by watchtower during the last scan | | `watchtower_containers_failed` | Gauge | Number of containers where update failed during the last scan | | `watchtower_scans_total` | Counter | Number of scans since the watchtower started | | `watchtower_scans_skipped` | Counter | Number of skipped scans since watchtower started | ## Example Prometheus `scrape_config` ```yaml scrape_configs: - job_name: watchtower scrape_interval: 5s metrics_path: /v1/metrics bearer_token: demotoken static_configs: - targets: - 'watchtower:8080' ``` Replace `demotoken` with the Bearer token you have set accordingly. ## Demo The repository contains a demo with prometheus and grafana, available through `docker-compose.yml`. This demo is preconfigured with a dashboard, which will look something like this: ![grafana metrics](assets/grafana-dashboard.png) ================================================ FILE: docs/notifications.md ================================================ # Notifications Watchtower can send notifications when containers are updated. Notifications are sent via hooks in the logging system, [logrus](http://github.com/sirupsen/logrus). !!! note "Using multiple notifications with environment variables" There is currently a bug in Viper (https://github.com/spf13/viper/issues/380), which prevents comma-separated slices to be used when using the environment variable. A workaround is available where we instead put quotes around the environment variable value and replace the commas with spaces: ``` WATCHTOWER_NOTIFICATIONS="slack msteams" ``` If you're a `docker-compose` user, make sure to specify environment variables' values in your `.yml` file without double quotes (`"`). This prevents unexpected errors when watchtower starts. ## Settings - `--notifications-level` (env. `WATCHTOWER_NOTIFICATIONS_LEVEL`): Controls the log level which is used for the notifications. If omitted, the default log level is `info`. Possible values are: `panic`, `fatal`, `error`, `warn`, `info`, `debug` or `trace`. - `--notifications-hostname` (env. `WATCHTOWER_NOTIFICATIONS_HOSTNAME`): Custom hostname specified in subject/title. Useful to override the operating system hostname. - `--notifications-delay` (env. `WATCHTOWER_NOTIFICATIONS_DELAY`): Delay before sending notifications expressed in seconds. - Watchtower will post a notification every time it is started. This behavior [can be changed](https://containrrr.github.io/watchtower/arguments/#without_sending_a_startup_message) with an argument. - `--notification-title-tag` (env. `WATCHTOWER_NOTIFICATION_TITLE_TAG`): Prefix to include in the title. Useful when running multiple watchtowers. - `--notification-skip-title` (env. `WATCHTOWER_NOTIFICATION_SKIP_TITLE`): Do not pass the title param to notifications. This will not pass a dynamic title override to notification services. If no title is configured for the service, it will remove the title all together. - `--notification-log-stdout` (env. `WATCHTOWER_NOTIFICATION_LOG_STDOUT`): Enable output from `logger://` shoutrrr service to stdout. ## [Shoutrrr](https://github.com/containrrr/shoutrrr) notifications To send notifications via shoutrrr, the following command-line options, or their corresponding environment variables, can be set: - `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used. This option can also reference a file, in which case the contents of the file are used. Go to [containrrr.dev/shoutrrr/v0.8/services/overview](https://containrrr.dev/shoutrrr/v0.8/services/overview) to learn more about the different service URLs you can use. You can define multiple services by space separating the URLs. (See example below) You can customize the message posted by setting a template. - `--notification-template` (env. `WATCHTOWER_NOTIFICATION_TEMPLATE`): The template used for the message. The template is a Go [template](https://golang.org/pkg/text/template/) that either format a list of [log entries](https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry) or a `notification.Data` struct. Simple templates are used unless the `notification-report` flag is specified: - `--notification-report` (env. `WATCHTOWER_NOTIFICATION_REPORT`): Use the session report as the notification template data. ## Simple templates The default value if not set is `{{range .}}{{.Message}}{{println}}{{end}}`. The example below uses a template that also outputs timestamp and log level. !!! tip "Custom date format" If you want to adjust the date/time format it must show how the [reference time](https://golang.org/pkg/time/#pkg-constants) (_Mon Jan 2 15:04:05 MST 2006_) would be displayed in your custom format. i.e., The day of the year has to be 1, the month has to be 2 (february), the hour 3 (or 15 for 24h time) etc. !!! note "Skipping notifications" To skip sending notifications that do not contain any information, you can wrap your template with `{{if .}}` and `{{end}}`. Example: ```bash docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ -e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \ -e WATCHTOWER_NOTIFICATION_TEMPLATE="{{range .}}{{.Time.Format \"2006-01-02 15:04:05\"}} ({{.Level}}): {{.Message}}{{println}}{{end}}" \ containrrr/watchtower ``` ## Report templates The default template for report notifications are the following: ```go {{- if .Report -}} {{- with .Report -}} {{- if ( or .Updated .Failed ) -}} {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed {{- range .Updated}} - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}} {{- end -}} {{- range .Fresh}} - {{.Name}} ({{.ImageName}}): {{.State}} {{- end -}} {{- range .Skipped}} - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} {{- end -}} {{- range .Failed}} - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} {{- end -}} {{- end -}} {{- end -}} {{- else -}} {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}} {{- end -}} ``` It will be used to send a summary of every session if there are any containers that were updated or which failed to update. !!! note "Skipping notifications" Whenever the result of applying the template results in an empty string, no notifications will be sent. This is by default used to limit the notifications to only be sent when there something noteworthy occurred. You can replace `{{- if ( or .Updated .Failed ) -}}` with any logic you want to decide when to send the notifications. Example using a custom report template that always sends a session report after each run: === "docker run" ```bash docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ -e WATCHTOWER_NOTIFICATION_REPORT="true" \ -e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \ -e WATCHTOWER_NOTIFICATION_TEMPLATE=" {{- if .Report -}} {{- with .Report -}} {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed {{- range .Updated}} - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}} {{- end -}} {{- range .Fresh}} - {{.Name}} ({{.ImageName}}): {{.State}} {{- end -}} {{- range .Skipped}} - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} {{- end -}} {{- range .Failed}} - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} {{- end -}} {{- end -}} {{- else -}} {{range .Entries -}}{{.Message}}{{\"\n\"}}{{- end -}} {{- end -}} " \ containrrr/watchtower ``` === "docker-compose" ``` yaml version: "3" services: watchtower: image: containrrr/watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock env: WATCHTOWER_NOTIFICATION_REPORT: "true" WATCHTOWER_NOTIFICATION_URL: > discord://token@channel slack://watchtower@token-a/token-b/token-c WATCHTOWER_NOTIFICATION_TEMPLATE: | {{- if .Report -}} {{- with .Report -}} {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed {{- range .Updated}} - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}} {{- end -}} {{- range .Fresh}} - {{.Name}} ({{.ImageName}}): {{.State}} {{- end -}} {{- range .Skipped}} - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} {{- end -}} {{- range .Failed}} - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} {{- end -}} {{- end -}} {{- else -}} {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}} {{- end -}} ``` ## Legacy notifications For backwards compatibility, the notifications can also be configured using legacy notification options. These will automatically be converted to shoutrrr URLs when used. The types of notifications to send are set by passing a comma-separated list of values to the `--notifications` option (or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values: - `email` to send notifications via e-mail - `slack` to send notifications through a Slack webhook - `msteams` to send notifications via MSTeams webhook - `gotify` to send notifications via Gotify ### `notify-upgrade` If watchtower is started with `notify-upgrade` as it's first argument, it will generate a .env file with your current legacy notification options converted to shoutrrr URLs. === "docker run" ```bash $ docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ -e WATCHTOWER_NOTIFICATIONS=slack \ -e WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL="https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy" \ containrrr/watchtower \ notify-upgrade ``` === "docker-compose.yml" ```yaml version: "3" services: watchtower: image: containrrr/watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock env: WATCHTOWER_NOTIFICATIONS: slack WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL: https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy command: notify-upgrade ``` You can then copy this file from the container (a message with the full command to do so will be logged) and use it with your current setup: === "docker run" ```bash $ docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ --env-file watchtower-notifications.env \ containrrr/watchtower ``` === "docker-compose.yml" ```yaml version: "3" services: watchtower: image: containrrr/watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock env_file: - watchtower-notifications.env ``` ### Email To receive notifications by email, the following command-line options, or their corresponding environment variables, can be set: - `--notification-email-from` (env. `WATCHTOWER_NOTIFICATION_EMAIL_FROM`): The e-mail address from which notifications will be sent. - `--notification-email-to` (env. `WATCHTOWER_NOTIFICATION_EMAIL_TO`): The e-mail address to which notifications will be sent. - `--notification-email-server` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER`): The SMTP server to send e-mails through. - `--notification-email-server-tls-skip-verify` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY`): Do not verify the TLS certificate of the mail server. This should be used only for testing. - `--notification-email-server-port` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT`): The port used to connect to the SMTP server to send e-mails through. Defaults to `25`. - `--notification-email-server-user` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER`): The username to authenticate with the SMTP server with. - `--notification-email-server-password` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD`): The password to authenticate with the SMTP server with. Can also reference a file, in which case the contents of the file are used. - `--notification-email-delay` (env. `WATCHTOWER_NOTIFICATION_EMAIL_DELAY`): Delay before sending notifications expressed in seconds. - `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers. **NOTE:** This will affect all notification types. Example: ```bash docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ -e WATCHTOWER_NOTIFICATIONS=email \ -e WATCHTOWER_NOTIFICATION_EMAIL_FROM=fromaddress@gmail.com \ -e WATCHTOWER_NOTIFICATION_EMAIL_TO=toaddress@gmail.com \ -e WATCHTOWER_NOTIFICATION_EMAIL_SERVER=smtp.gmail.com \ -e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT=587 \ -e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER=fromaddress@gmail.com \ -e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD=app_password \ -e WATCHTOWER_NOTIFICATION_EMAIL_DELAY=2 \ containrrr/watchtower ``` The previous example assumes, that you already have an SMTP server up and running you can connect to. If you don't or you want to bring up watchtower with your own simple SMTP relay the following `docker-compose.yml` might be a good start for you. The following example assumes, that your domain is called `your-domain.com` and that you are going to use a certificate valid for `smtp.your-domain.com`. This hostname has to be used as `WATCHTOWER_NOTIFICATION_EMAIL_SERVER` otherwise the TLS connection is going to fail with `Failed to send notification email` or `connect: connection refused`. We also have to add a network for this setup in order to add an alias to it. If you also want to enable DKIM or other features on the SMTP server, you will find more information at [freinet/postfix-relay](https://hub.docker.com/r/freinet/postfix-relay). Example including an SMTP relay: ```yaml version: '3.8' services: watchtower: image: containrrr/watchtower:latest container_name: watchtower environment: WATCHTOWER_MONITOR_ONLY: 'true' WATCHTOWER_NOTIFICATIONS: email WATCHTOWER_NOTIFICATION_EMAIL_FROM: from-address@your-domain.com WATCHTOWER_NOTIFICATION_EMAIL_TO: to-address@your-domain.com # you have to use a network alias here, if you use your own certificate WATCHTOWER_NOTIFICATION_EMAIL_SERVER: smtp.your-domain.com WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT: 25 WATCHTOWER_NOTIFICATION_EMAIL_DELAY: 2 volumes: - /var/run/docker.sock:/var/run/docker.sock networks: - watchtower depends_on: - postfix # SMTP needed to send out status emails postfix: image: freinet/postfix-relay:latest expose: - 25 environment: MAILNAME: somename.your-domain.com TLS_KEY: '/etc/ssl/domains/your-domain.com/your-domain.com.key' TLS_CRT: '/etc/ssl/domains/your-domain.com/your-domain.com.crt' TLS_CA: '/etc/ssl/domains/your-domain.com/intermediate.crt' volumes: - /etc/ssl/domains/your-domain.com/:/etc/ssl/domains/your-domain.com/:ro networks: watchtower: # this alias is really important to make your certificate work aliases: - smtp.your-domain.com networks: watchtower: external: false ``` ### Slack To receive notifications in Slack, add `slack` to the `--notifications` option or the `WATCHTOWER_NOTIFICATIONS` environment variable. Additionally, you should set the Slack webhook URL using the `--notification-slack-hook-url` option or the `WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL` environment variable. This option can also reference a file, in which case the contents of the file are used. By default, watchtower will send messages under the name `watchtower`, you can customize this string through the `--notification-slack-identifier` option or the `WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER` environment variable. Other, optional, variables include: - `--notification-slack-channel` (env. `WATCHTOWER_NOTIFICATION_SLACK_CHANNEL`): A string which overrides the webhook's default channel. Example: #my-custom-channel. Example: ```bash docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ -e WATCHTOWER_NOTIFICATIONS=slack \ -e WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL="https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy" \ -e WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER=watchtower-server-1 \ -e WATCHTOWER_NOTIFICATION_SLACK_CHANNEL=#my-custom-channel \ containrrr/watchtower ``` ### Microsoft Teams To receive notifications in MSTeams channel, add `msteams` to the `--notifications` option or the `WATCHTOWER_NOTIFICATIONS` environment variable. Additionally, you should set the MSTeams webhook URL using the `--notification-msteams-hook` option or the `WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL` environment variable. This option can also reference a file, in which case the contents of the file are used. MSTeams notifier could send keys/values filled by `log.WithField` or `log.WithFields` as MSTeams message facts. To enable this feature add `--notification-msteams-data` flag or set `WATCHTOWER_NOTIFICATION_MSTEAMS_USE_LOG_DATA=true` environment variable. Example: ```bash docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ -e WATCHTOWER_NOTIFICATIONS=msteams \ -e WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL="https://outlook.office.com/webhook/xxxxxxxx@xxxxxxx/IncomingWebhook/yyyyyyyy/zzzzzzzzzz" \ -e WATCHTOWER_NOTIFICATION_MSTEAMS_USE_LOG_DATA=true \ containrrr/watchtower ``` ### Gotify To push a notification to your Gotify instance, register a Gotify app and specify the Gotify URL and app token: ```bash docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ -e WATCHTOWER_NOTIFICATIONS=gotify \ -e WATCHTOWER_NOTIFICATION_GOTIFY_URL="https://my.gotify.tld/" \ -e WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN="SuperSecretToken" \ containrrr/watchtower ``` `-e WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN` or `--notification-gotify-token` can also reference a file, in which case the contents of the file are used. If you want to disable TLS verification for the Gotify instance, you can use either `-e WATCHTOWER_NOTIFICATION_GOTIFY_TLS_SKIP_VERIFY=true` or `--notification-gotify-tls-skip-verify`. ================================================ FILE: docs/private-registries.md ================================================ Watchtower supports private Docker image registries. In many cases, accessing a private registry requires a valid username and password (i.e., _credentials_). In order to operate in such an environment, watchtower needs to know the credentials to access the registry. The credentials can be provided to watchtower in a configuration file called `config.json`. There are two ways to generate this configuration file: * The configuration file can be created manually. * Call `docker login ` and share the resulting configuration file. ### Create the configuration file manually Create a new configuration file with the following syntax and a base64 encoded username and password `auth` string: ```json { "auths": { "": { "auth": "XXXXXXX" } } } ``` `` needs to be replaced by the name of your private registry (e.g., `my-private-registry.example.org`). !!! info "Using private images on Docker Hub" To access private repositories on Docker Hub, `` should be `https://index.docker.io/v1/`. In this special case, the registry domain does not have to be specified in `docker run` or `docker-compose`. Like Docker, Watchtower will use the Docker Hub registry and its credentials when no registry domain is specified. Watchtower will recognize credentials with `` `index.docker.io`, but the Docker CLI will not. !!! important "Using a private registry on a local host" To use a private registry hosted locally, make sure to correctly specify the registry host in both `config.json` and the `docker run` command or `docker-compose` file. Valid hosts are `localhost[:PORT]`, `HOST:PORT`, or any multi-part `domain.name` or IP-address with or without a port. Examples: * `localhost` -> `localhost/myimage` * `127.0.0.1` -> `127.0.0.1/myimage:mytag` * `host.domain` -> `host.domain/myorganization/myimage` * `other-lan-host:80` -> `other-lan-host:80/imagename:latest` The required `auth` string can be generated as follows: ```bash echo -n 'username:password' | base64 ``` !!! info "Username and Password for GCloud" For gcloud, we'll use `_json_key` as our username and the content of `gcloudauth.json` as the password. ``` bash echo -n "_json_key:$(cat gcloudauth.json)" | base64 -w0 ``` When the watchtower Docker container is started, the created configuration file (`/config.json` in this example) needs to be passed to the container: ```bash docker run [...] -v /config.json:/config.json containrrr/watchtower ``` ### Share the Docker configuration file To pull an image from a private registry, `docker login` needs to be called first, to get access to the registry. The provided credentials are stored in a configuration file called `/.docker/config.json`. This configuration file can be directly used by watchtower. In this case, the creation of an additional configuration file is not necessary. When the Docker container is started, pass the configuration file to watchtower: ```bash docker run [...] -v /.docker/config.json:/config.json containrrr/watchtower ``` When creating the watchtower container via docker-compose, use the following lines: ```yaml version: "3.4" services: watchtower: image: containrrr/watchtower:latest volumes: - /var/run/docker.sock:/var/run/docker.sock - /.docker/config.json:/config.json ... ``` #### Docker Config path By default, watchtower will look for the `config.json` file in `/`, but this can be changed by setting the `DOCKER_CONFIG` environment variable to the directory path where your config is located. This is useful for setups where the config.json file is changed while the watchtower instance is running, as the changes will not be picked up for a mounted file if the inode changes. Example usage: ```yaml version: "3.4" services: watchtower: image: containrrr/watchtower environment: DOCKER_CONFIG: /config volumes: - /etc/watchtower/config/:/config/ - /var/run/docker.sock:/var/run/docker.sock ``` ## Credential helpers Some private Docker registries (the most prominent probably being AWS ECR) use non-standard ways of authentication. To be able to use this together with watchtower, we need to use a credential helper. To keep the image size small we've decided to not include any helpers in the watchtower image, instead we'll put the helper in a separate container and mount it using volumes. ### Example Example implementation for use with [amazon-ecr-credential-helper](https://github.com/awslabs/amazon-ecr-credential-helper): Use the dockerfile below to build the [amazon-ecr-credential-helper](https://github.com/awslabs/amazon-ecr-credential-helper), in a volume that may be mounted onto your watchtower container. 1. Create the Dockerfile (contents below): ```Dockerfile FROM golang:1.20 ENV GO111MODULE off ENV CGO_ENABLED 0 ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login RUN go get -u $REPO RUN rm /go/bin/docker-credential-ecr-login RUN go build \ -o /go/bin/docker-credential-ecr-login \ /go/src/$REPO WORKDIR /go/bin/ ``` 2. Use the following commands to build the aws-ecr-dock-cred-helper and store it's output in a volume: ```bash # Create a volume to store the command (once built) docker volume create helper # Build the container docker build -t aws-ecr-dock-cred-helper . # Build the command and store it in the new volume in the /go/bin directory. docker run -d --rm --name aws-cred-helper \ --volume helper:/go/bin aws-ecr-dock-cred-helper ``` 3. Create a configuration file for docker, and store it in $HOME/.docker/config.json (replace the placeholders with your AWS Account ID and with your AWS ECR Region): ```json { "credsStore" : "ecr-login", "HttpHeaders" : { "User-Agent" : "Docker-Client/19.03.1 (XXXXXX)" }, "auths" : { ".dkr.ecr..amazonaws.com" : {} }, "credHelpers": { ".dkr.ecr..amazonaws.com" : "ecr-login" } } ``` 4. Create a docker-compose file (as an example) to help launch the container: ```yaml version: "3.4" services: # Check for new images and restart things if a new image exists # for any of our containers. watchtower: image: containrrr/watchtower:latest volumes: - /var/run/docker.sock:/var/run/docker.sock - .docker/config.json:/config.json - helper:/go/bin environment: - HOME=/ - PATH=$PATH:/go/bin - AWS_REGION=us-west-1 volumes: helper: external: true ``` A few additional notes: 1. With docker-compose the volume (helper, in this case) MUST be set to `external: true`, otherwise docker-compose will preface it with the directory name. 2. Note that "credsStore" : "ecr-login" is needed - and in theory if you have that you can remove the credHelpers section 3. I have this running on an EC2 instance that has credentials assigned to it - so no keys are needed; however, you may need to include the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables as well. 4. An alternative to adding the various variables is to create a ~/.aws/config and ~/.aws/credentials files and place the settings there, then mount the ~/.aws directory to / in the container. ================================================ FILE: docs/remote-hosts.md ================================================ By default, watchtower is set-up to monitor the local Docker daemon (the same daemon running the watchtower container itself). However, it is possible to configure watchtower to monitor a remote Docker endpoint. When starting the watchtower container you can specify a remote Docker endpoint with either the `--host` flag or the `DOCKER_HOST` environment variable: ```bash docker run -d \ --name watchtower \ containrrr/watchtower --host "tcp://10.0.1.2:2375" ``` or ```bash docker run -d \ --name watchtower \ -e DOCKER_HOST="tcp://10.0.1.2:2375" \ containrrr/watchtower ``` Note in both of the examples above that it is unnecessary to mount the _/var/run/docker.sock_ into the watchtower container. ================================================ FILE: docs/running-multiple-instances.md ================================================ By default, Watchtower will clean up other instances and won't allow multiple instances running on the same Docker host or swarm. It is possible to override this behavior by defining a [scope](https://containrrr.github.io/watchtower/arguments/#filter_by_scope) to each running instance. !!! note - Multiple instances can't run with the same scope; - An instance without a scope will clean up other running instances, even if they have a defined scope; - Supplying `none` as the scope will treat `com.centurylinklabs.watchtower.scope=none`, `com.centurylinklabs.watchtower.scope=` and the lack of a `com.centurylinklabs.watchtower.scope` label as the scope `none`. This effectly enables you to run both scoped and unscoped watchtower instances on the same machine. To define an instance monitoring scope, use the `--scope` argument or the `WATCHTOWER_SCOPE` environment variable on startup and set the `com.centurylinklabs.watchtower.scope` label with the same value for the containers you want to include in this instance's scope (including the instance itself). For example, in a Docker Compose config file: ```yaml version: '3' services: app-with-scope: image: myapps/monitored-by-watchtower labels: [ "com.centurylinklabs.watchtower.scope=myscope" ] scoped-watchtower: image: containrrr/watchtower volumes: [ "/var/run/docker.sock:/var/run/docker.sock" ] command: --interval 30 --scope myscope labels: [ "com.centurylinklabs.watchtower.scope=myscope" ] unscoped-app-a: image: myapps/app-a unscoped-app-b: image: myapps/app-b labels: [ "com.centurylinklabs.watchtower.scope=none" ] unscoped-app-c: image: myapps/app-b labels: [ "com.centurylinklabs.watchtower.scope=" ] unscoped-watchtower: image: containrrr/watchtower volumes: [ "/var/run/docker.sock:/var/run/docker.sock" ] command: --interval 30 --scope none ``` ================================================ FILE: docs/secure-connections.md ================================================ Watchtower is also capable of connecting to Docker endpoints which are protected by SSL/TLS. If you've used _docker-machine_ to provision your remote Docker host, you simply need to volume mount the certificates generated by _docker-machine_ into the watchtower container and optionally specify `--tlsverify` flag. The _docker-machine_ certificates for a particular host can be located by executing the `docker-machine env` command for the desired host (note the values for the `DOCKER_HOST` and `DOCKER_CERT_PATH` environment variables that are returned from this command). The directory containing the certificates for the remote host needs to be mounted into the watchtower container at _/etc/ssl/docker_. With the certificates mounted into the watchtower container you need to specify the `--tlsverify` flag to enable verification of the certificate: ```bash docker run -d \ --name watchtower \ -e DOCKER_HOST=$DOCKER_HOST \ -e DOCKER_CERT_PATH=/etc/ssl/docker \ -v $DOCKER_CERT_PATH:/etc/ssl/docker \ containrrr/watchtower --tlsverify ``` ================================================ FILE: docs/stop-signals.md ================================================ When watchtower detects that a running container needs to be updated it will stop the container by sending it a SIGTERM signal. If your container should be shutdown with a different signal you can communicate this to watchtower by setting a label named _com.centurylinklabs.watchtower.stop-signal_ with the value of the desired signal. This label can be coded directly into your image by using the `LABEL` instruction in your Dockerfile: ```docker LABEL com.centurylinklabs.watchtower.stop-signal="SIGHUP" ``` Or, it can be specified as part of the `docker run` command line: ```bash docker run -d --label=com.centurylinklabs.watchtower.stop-signal=SIGHUP someimage ``` ================================================ FILE: docs/stylesheets/theme.css ================================================ [data-md-color-scheme="containrrr"] { /* Primary and accent */ --md-primary-fg-color: #406170; --md-primary-fg-color--light:#acbfc7; --md-primary-fg-color--dark: #003343; --md-accent-fg-color: #003343; --md-accent-fg-color--transparent: #00334310; /* Typeset overrides */ --md-typeset-a-color: var(--md-primary-fg-color); } [data-md-color-scheme="containrrr-dark"] { --md-hue: 199; /* Primary and accent */ --md-primary-fg-color: hsl(199deg 27% 35% / 100%); --md-primary-fg-color--link: hsl(199deg 45% 65% / 100%); --md-primary-fg-color--light: hsl(198deg 19% 73% / 100%); --md-primary-fg-color--dark: hsl(194deg 100% 13% / 100%); --md-accent-fg-color: hsl(194deg 45% 50% / 100%); --md-accent-fg-color--transparent: hsl(194deg 45% 50% / 6.3%); /* Default */ --md-default-fg-color: hsl(var(--md-hue) 75% 95% / 100%); --md-default-fg-color--light: hsl(var(--md-hue) 75% 90% / 62%); --md-default-fg-color--lighter: hsl(var(--md-hue) 75% 90% / 32%); --md-default-fg-color--lightest: hsl(var(--md-hue) 75% 90% / 12%); --md-default-bg-color: hsl(var(--md-hue) 15% 21% / 100%); --md-default-bg-color--light: hsl(var(--md-hue) 15% 21% / 54%); --md-default-bg-color--lighter: hsl(var(--md-hue) 15% 21% / 26%); --md-default-bg-color--lightest: hsl(var(--md-hue) 15% 21% / 7%); /* Code */ --md-code-fg-color: hsl(var(--md-hue) 18% 86% / 100%); --md-code-bg-color: hsl(var(--md-hue) 15% 15% / 100%); --md-code-hl-color: hsl(218deg 100% 63% / 15%); --md-code-hl-number-color: hsl(346deg 74% 63% / 100%); --md-code-hl-special-color: hsl(320deg 83% 66% / 100%); --md-code-hl-function-color: hsl(271deg 57% 65% / 100%); --md-code-hl-constant-color: hsl(230deg 62% 70% / 100%); --md-code-hl-keyword-color: hsl(199deg 33% 64% / 100%); --md-code-hl-string-color: hsl( 50deg 34% 74% / 100%); --md-code-hl-name-color: var(--md-code-fg-color); --md-code-hl-operator-color: var(--md-default-fg-color--light); --md-code-hl-punctuation-color: var(--md-default-fg-color--light); --md-code-hl-comment-color: var(--md-default-fg-color--light); --md-code-hl-generic-color: var(--md-default-fg-color--light); --md-code-hl-variable-color: hsl(241deg 22% 60% / 100%); /* Typeset */ --md-typeset-color: var(--md-default-fg-color); --md-typeset-a-color: var(--md-primary-fg-color--link); --md-typeset-mark-color: hsl(218deg 100% 63% / 30%); --md-typeset-kbd-color: hsl(var(--md-hue) 15% 94% / 12%); --md-typeset-kbd-accent-color: hsl(var(--md-hue) 15% 94% / 20%); --md-typeset-kbd-border-color: hsl(var(--md-hue) 15% 14% / 100%); --md-typeset-table-color: hsl(var(--md-hue) 75% 95% / 12%); /* Admonition */ --md-admonition-fg-color: var(--md-default-fg-color); --md-admonition-bg-color: var(--md-default-bg-color); /* Footer */ --md-footer-bg-color: hsl(var(--md-hue) 15% 12% / 87%); --md-footer-bg-color--dark: hsl(var(--md-hue) 15% 10% / 100%); /* Shadows */ --md-shadow-z1: 0 0.2rem 0.50rem rgba(0 0 0 20%), 0 0 0.05rem rgba(0 0 0 10%); --md-shadow-z2: 0 0.2rem 0.50rem rgba(0 0 0 30%), 0 0 0.05rem rgba(0 0 0 25%); --md-shadow-z3: 0 0.2rem 0.50rem rgba(0 0 0 40%), 0 0 0.05rem rgba(0 0 0 35%); } .md-header-nav__button.md-logo { padding: 0; } .md-header-nav__button.md-logo img { width: 1.6rem; height: 1.6rem; } ================================================ FILE: docs/template-preview.md ================================================
loading wasm...

================================================ FILE: docs/updating.md ================================================ ## Updating Watchtower If watchtower is monitoring the same Docker daemon under which the watchtower container itself is running (i.e. if you volume-mounted `/var/run/docker.sock` into the watchtower container) then it has the ability to update itself. If a new version of the `containrrr/watchtower` image is pushed to the Docker Hub, your watchtower will pull down the new image and restart itself automatically. ================================================ FILE: docs/usage-overview.md ================================================ Watchtower is itself packaged as a Docker container so installation is as simple as pulling the `containrrr/watchtower` image. If you are using ARM based architecture, pull the appropriate `containrrr/watchtower:armhf-` image from the [containrrr Docker Hub](https://hub.docker.com/r/containrrr/watchtower/tags/). Since the watchtower code needs to interact with the Docker API in order to monitor the running containers, you need to mount _/var/run/docker.sock_ into the container with the `-v` flag when you run it. Run the `watchtower` container with the following command: ```bash docker run -d \ --name watchtower \ -v /var/run/docker.sock:/var/run/docker.sock \ containrrr/watchtower ``` If pulling images from private Docker registries, supply registry authentication credentials with the environment variables `REPO_USER` and `REPO_PASS` or by mounting the host's docker config file into the container (at the root of the container filesystem `/`). Passing environment variables: ```bash docker run -d \ --name watchtower \ -e REPO_USER=username \ -e REPO_PASS=password \ -v /var/run/docker.sock:/var/run/docker.sock \ containrrr/watchtower container_to_watch --debug ``` Also check out [this Stack Overflow answer](https://stackoverflow.com/a/30494145/7872793) for more options on how to pass environment variables. Alternatively if you 2FA authentication setup on Docker Hub then passing username and password will be insufficient. Instead you can run `docker login` to store your credentials in `$HOME/.docker/config.json` and then mount this config file to make it available to the Watchtower container: ```bash docker run -d \ --name watchtower \ -v $HOME/.docker/config.json:/config.json \ -v /var/run/docker.sock:/var/run/docker.sock \ containrrr/watchtower container_to_watch --debug ``` !!! note "Changes to config.json while running" If you mount `config.json` in the manner above, changes from the host system will (generally) not be propagated to the running container. Mounting files into the Docker daemon uses bind mounts, which are based on inodes. Most applications (including `docker login` and `vim`) will not directly edit the file, but instead make a copy and replace the original file, which results in a new inode which in turn _breaks_ the bind mount. **As a workaround**, you can create a symlink to your `config.json` file and then mount the symlink in the container. The symlinked file will always have the same inode, which keeps the bind mount intact and will ensure changes to the original file are propagated to the running container (regardless of the inode of the source file!). If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container from a private repo on the GitHub Registry and monitors it with watchtower. Note the command argument changing the interval to 30s rather than the default 24 hours. ```yaml version: "3" services: cavo: image: ghcr.io//: ports: - "443:3443" - "80:3080" watchtower: image: containrrr/watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock - /root/.docker/config.json:/config.json command: --interval 30 ``` ================================================ FILE: docs-requirements.txt ================================================ mkdocs mkdocs-material md-toc ================================================ FILE: go.mod ================================================ module github.com/containrrr/watchtower go 1.20 require ( github.com/containrrr/shoutrrr v0.8.0 github.com/distribution/reference v0.5.0 github.com/docker/cli v24.0.7+incompatible github.com/docker/docker v24.0.7+incompatible github.com/docker/go-connections v0.4.0 github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.30.0 github.com/prometheus/client_golang v1.18.0 github.com/robfig/cron v1.2.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.8.4 golang.org/x/net v0.19.0 ) require github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.4.17 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.6.1 // indirect github.com/docker/go-units v0.4.0 // indirect github.com/fatih/color v1.15.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c // indirect github.com/nxadm/tail v1.4.8 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/stretchr/objx v0.5.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/text v0.14.0 golang.org/x/time v0.5.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.0.3 // indirect ) ================================================ FILE: go.sum ================================================ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.4.17 h1:iT12IBVClFevaf8PuVyi3UmZOVh4OqnaLxDTW2O6j3w= github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v24.0.7+incompatible h1:wa/nIwYFW7BVTGa7SWPVyyXU9lgORqUb1xfI36MSkFg= github.com/docker/cli v24.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM= github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.6.1 h1:Dq4iIfcM7cNtddhLVWe9h4QDjsi4OER3Z8voPu/I52g= github.com/docker/docker-credential-helpers v0.6.1/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= 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.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= ================================================ FILE: goreleaser.yml ================================================ build: main: ./main.go binary: watchtower goos: - linux - windows goarch: - amd64 - 386 - arm - arm64 ldflags: - -s -w -X github.com/containrrr/watchtower/internal/meta.Version={{.Version}} archives: - name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}" format: tar.gz replacements: arm: armhf arm64: arm64v8 amd64: amd64 386: 386 darwin: macOS linux: linux format_overrides: - goos: windows format: zip files: - LICENSE.md dockers: - use_buildx: true build_flag_templates: [ "--platform=linux/amd64" ] goos: linux goarch: amd64 goarm: '' dockerfile: dockerfiles/Dockerfile image_templates: - containrrr/watchtower:amd64-{{ .Version }} - containrrr/watchtower:amd64-latest - ghcr.io/containrrr/watchtower:amd64-{{ .Version }} - ghcr.io/containrrr/watchtower:amd64-latest binaries: - watchtower - use_buildx: true build_flag_templates: [ "--platform=linux/386" ] goos: linux goarch: 386 goarm: '' dockerfile: dockerfiles/Dockerfile image_templates: - containrrr/watchtower:i386-{{ .Version }} - containrrr/watchtower:i386-latest - ghcr.io/containrrr/watchtower:i386-{{ .Version }} - ghcr.io/containrrr/watchtower:i386-latest binaries: - watchtower - use_buildx: true build_flag_templates: [ "--platform=linux/arm/v6" ] goos: linux goarch: arm goarm: 6 dockerfile: dockerfiles/Dockerfile image_templates: - containrrr/watchtower:armhf-{{ .Version }} - containrrr/watchtower:armhf-latest - ghcr.io/containrrr/watchtower:armhf-{{ .Version }} - ghcr.io/containrrr/watchtower:armhf-latest binaries: - watchtower - use_buildx: true build_flag_templates: [ "--platform=linux/arm64/v8" ] goos: linux goarch: arm64 goarm: '' dockerfile: dockerfiles/Dockerfile image_templates: - containrrr/watchtower:arm64v8-{{ .Version }} - containrrr/watchtower:arm64v8-latest - ghcr.io/containrrr/watchtower:arm64v8-{{ .Version }} - ghcr.io/containrrr/watchtower:arm64v8-latest binaries: - watchtower ================================================ FILE: grafana/dashboards/dashboard.json ================================================ { "annotations": { "list": [ { "builtIn": 1, "datasource": "-- Grafana --", "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "editable": true, "gnetId": null, "graphTooltip": 0, "id": 1, "links": [], "panels": [ { "datasource": "Prometheus", "fieldConfig": { "defaults": { "custom": {}, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null } ] } }, "overrides": [] }, "gridPos": { "h": 4, "w": 1, "x": 0, "y": 0 }, "id": 2, "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, "textMode": "auto" }, "pluginVersion": "7.3.6", "targets": [ { "expr": "watchtower_scans_total", "interval": "", "legendFormat": "", "refId": "A" } ], "timeFrom": null, "timeShift": null, "title": "Total Scans", "type": "stat" }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, "datasource": null, "fieldConfig": { "defaults": { "custom": {} }, "overrides": [ { "matcher": { "id": "byName", "options": "watchtower_containers_scanned{instance=\"watchtower:8080\", job=\"watchtower\"}" }, "properties": [ { "id": "displayName", "value": "Scanned" } ] }, { "matcher": { "id": "byName", "options": "watchtower_containers_failed{instance=\"watchtower:8080\", job=\"watchtower\"}" }, "properties": [ { "id": "displayName", "value": "Failed" } ] }, { "matcher": { "id": "byName", "options": "watchtower_containers_updated{instance=\"watchtower:8080\", job=\"watchtower\"}" }, "properties": [ { "id": "displayName", "value": "Updated" } ] } ] }, "fill": 1, "fillGradient": 0, "gridPos": { "h": 8, "w": 6, "x": 1, "y": 0 }, "hiddenSeries": false, "id": 5, "legend": { "avg": false, "current": false, "max": false, "min": false, "show": true, "total": false, "values": false }, "lines": true, "linewidth": 1, "nullPointMode": "null as zero", "options": { "alertThreshold": true }, "percentage": false, "pluginVersion": "7.3.6", "pointradius": 2, "points": false, "renderer": "flot", "seriesOverrides": [], "spaceLength": 10, "stack": false, "steppedLine": false, "targets": [ { "expr": "watchtower_containers_scanned", "interval": "", "legendFormat": "", "refId": "A" }, { "expr": "watchtower_containers_failed", "interval": "", "legendFormat": "", "refId": "B" }, { "expr": "watchtower_containers_updated", "interval": "", "legendFormat": "", "refId": "C" } ], "thresholds": [], "timeFrom": null, "timeRegions": [], "timeShift": null, "title": "Container Updates", "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, "type": "graph", "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, "yaxes": [ { "decimals": 0, "format": "short", "label": "", "logBase": 1, "max": null, "min": "0", "show": true }, { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } ], "yaxis": { "align": false, "alignLevel": null } }, { "datasource": "Prometheus", "fieldConfig": { "defaults": { "custom": {}, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 4, "w": 1, "x": 0, "y": 4 }, "id": 3, "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, "textMode": "auto" }, "pluginVersion": "7.3.6", "targets": [ { "expr": "watchtower_scans_skipped", "interval": "", "legendFormat": "", "refId": "A" } ], "timeFrom": null, "timeShift": null, "title": "Skipped Scans", "type": "stat" } ], "refresh": false, "schemaVersion": 26, "style": "dark", "tags": [], "templating": { "list": [] }, "time": { "from": "now-1h", "to": "now" }, "timepicker": {}, "timezone": "", "title": "Watchtower", "uid": "d7bdoT-Gz", "version": 1 } ================================================ FILE: grafana/dashboards/dashboard.yml ================================================ apiVersion: 1 providers: - name: 'Prometheus' orgId: 1 folder: '' type: file disableDeletion: false editable: true options: path: /etc/grafana/provisioning/dashboards ================================================ FILE: grafana/datasources/datasource.yml ================================================ apiVersion: 1 datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: true ================================================ FILE: internal/actions/actions_suite_test.go ================================================ package actions_test import ( "testing" "time" "github.com/sirupsen/logrus" "github.com/containrrr/watchtower/internal/actions" "github.com/containrrr/watchtower/pkg/types" . "github.com/containrrr/watchtower/internal/actions/mocks" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestActions(t *testing.T) { RegisterFailHandler(Fail) logrus.SetOutput(GinkgoWriter) RunSpecs(t, "Actions Suite") } var _ = Describe("the actions package", func() { Describe("the check prerequisites method", func() { When("given an empty array", func() { It("should not do anything", func() { client := CreateMockClient( &TestData{}, // pullImages: false, // removeVolumes: false, ) Expect(actions.CheckForMultipleWatchtowerInstances(client, false, "")).To(Succeed()) }) }) When("given an array of one", func() { It("should not do anything", func() { client := CreateMockClient( &TestData{ Containers: []types.Container{ CreateMockContainer( "test-container", "test-container", "watchtower", time.Now()), }, }, // pullImages: false, // removeVolumes: false, ) Expect(actions.CheckForMultipleWatchtowerInstances(client, false, "")).To(Succeed()) }) }) When("given multiple containers", func() { var client MockClient BeforeEach(func() { client = CreateMockClient( &TestData{ NameOfContainerToKeep: "test-container-02", Containers: []types.Container{ CreateMockContainer( "test-container-01", "test-container-01", "watchtower", time.Now().AddDate(0, 0, -1)), CreateMockContainer( "test-container-02", "test-container-02", "watchtower", time.Now()), }, }, // pullImages: false, // removeVolumes: false, ) }) It("should stop all but the latest one", func() { err := actions.CheckForMultipleWatchtowerInstances(client, false, "") Expect(err).NotTo(HaveOccurred()) }) }) When("deciding whether to cleanup images", func() { var client MockClient BeforeEach(func() { client = CreateMockClient( &TestData{ Containers: []types.Container{ CreateMockContainer( "test-container-01", "test-container-01", "watchtower", time.Now().AddDate(0, 0, -1)), CreateMockContainer( "test-container-02", "test-container-02", "watchtower", time.Now()), }, }, // pullImages: false, // removeVolumes: false, ) }) It("should try to delete the image if the cleanup flag is true", func() { err := actions.CheckForMultipleWatchtowerInstances(client, true, "") Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImage()).To(BeTrue()) }) It("should not try to delete the image if the cleanup flag is false", func() { err := actions.CheckForMultipleWatchtowerInstances(client, false, "") Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImage()).To(BeFalse()) }) }) }) }) ================================================ FILE: internal/actions/check.go ================================================ package actions import ( "fmt" "sort" "time" "github.com/containrrr/watchtower/pkg/container" "github.com/containrrr/watchtower/pkg/filters" "github.com/containrrr/watchtower/pkg/sorter" "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" ) // CheckForSanity makes sure everything is sane before starting func CheckForSanity(client container.Client, filter types.Filter, rollingRestarts bool) error { log.Debug("Making sure everything is sane before starting") if rollingRestarts { containers, err := client.ListContainers(filter) if err != nil { return err } for _, c := range containers { if len(c.Links()) > 0 { return fmt.Errorf( "%q is depending on at least one other container. This is not compatible with rolling restarts", c.Name(), ) } } } return nil } // CheckForMultipleWatchtowerInstances will ensure that there are not multiple instances of the // watchtower running simultaneously. If multiple watchtower containers are detected, this function // will stop and remove all but the most recently started container. This behaviour can be bypassed // if a scope UID is defined. func CheckForMultipleWatchtowerInstances(client container.Client, cleanup bool, scope string) error { filter := filters.WatchtowerContainersFilter if scope != "" { filter = filters.FilterByScope(scope, filter) } containers, err := client.ListContainers(filter) if err != nil { return err } if len(containers) <= 1 { log.Debug("There are no additional watchtower containers") return nil } log.Info("Found multiple running watchtower instances. Cleaning up.") return cleanupExcessWatchtowers(containers, client, cleanup) } func cleanupExcessWatchtowers(containers []types.Container, client container.Client, cleanup bool) error { var stopErrors int sort.Sort(sorter.ByCreated(containers)) allContainersExceptLast := containers[0 : len(containers)-1] for _, c := range allContainersExceptLast { if err := client.StopContainer(c, 10*time.Minute); err != nil { // logging the original here as we're just returning a count log.WithError(err).Error("Could not stop a previous watchtower instance.") stopErrors++ continue } if cleanup { if err := client.RemoveImageByID(c.ImageID()); err != nil { log.WithError(err).Warning("Could not cleanup watchtower images, possibly because of other watchtowers instances in other scopes.") } } } if stopErrors > 0 { return fmt.Errorf("%d errors while stopping watchtower containers", stopErrors) } return nil } ================================================ FILE: internal/actions/mocks/client.go ================================================ package mocks import ( "errors" "fmt" "time" t "github.com/containrrr/watchtower/pkg/types" ) // MockClient is a mock that passes as a watchtower Client type MockClient struct { TestData *TestData pullImages bool removeVolumes bool } // TestData is the data used to perform the test type TestData struct { TriedToRemoveImageCount int NameOfContainerToKeep string Containers []t.Container Staleness map[string]bool } // TriedToRemoveImage is a test helper function to check whether RemoveImageByID has been called func (testdata *TestData) TriedToRemoveImage() bool { return testdata.TriedToRemoveImageCount > 0 } // CreateMockClient creates a mock watchtower Client for usage in tests func CreateMockClient(data *TestData, pullImages bool, removeVolumes bool) MockClient { return MockClient{ data, pullImages, removeVolumes, } } // ListContainers is a mock method returning the provided container testdata func (client MockClient) ListContainers(_ t.Filter) ([]t.Container, error) { return client.TestData.Containers, nil } // StopContainer is a mock method func (client MockClient) StopContainer(c t.Container, _ time.Duration) error { if c.Name() == client.TestData.NameOfContainerToKeep { return errors.New("tried to stop the instance we want to keep") } return nil } // StartContainer is a mock method func (client MockClient) StartContainer(_ t.Container) (t.ContainerID, error) { return "", nil } // RenameContainer is a mock method func (client MockClient) RenameContainer(_ t.Container, _ string) error { return nil } // RemoveImageByID increments the TriedToRemoveImageCount on being called func (client MockClient) RemoveImageByID(_ t.ImageID) error { client.TestData.TriedToRemoveImageCount++ return nil } // GetContainer is a mock method func (client MockClient) GetContainer(_ t.ContainerID) (t.Container, error) { return client.TestData.Containers[0], nil } // ExecuteCommand is a mock method func (client MockClient) ExecuteCommand(_ t.ContainerID, command string, _ int) (SkipUpdate bool, err error) { switch command { case "/PreUpdateReturn0.sh": return false, nil case "/PreUpdateReturn1.sh": return false, fmt.Errorf("command exited with code 1") case "/PreUpdateReturn75.sh": return true, nil default: return false, nil } } // IsContainerStale is true if not explicitly stated in TestData for the mock client func (client MockClient) IsContainerStale(cont t.Container, params t.UpdateParams) (bool, t.ImageID, error) { stale, found := client.TestData.Staleness[cont.Name()] if !found { stale = true } return stale, "", nil } // WarnOnHeadPullFailed is always true for the mock client func (client MockClient) WarnOnHeadPullFailed(_ t.Container) bool { return true } ================================================ FILE: internal/actions/mocks/container.go ================================================ package mocks import ( "fmt" "strconv" "strings" "time" "github.com/containrrr/watchtower/pkg/container" wt "github.com/containrrr/watchtower/pkg/types" "github.com/docker/docker/api/types" dockerContainer "github.com/docker/docker/api/types/container" "github.com/docker/go-connections/nat" ) // CreateMockContainer creates a container substitute valid for testing func CreateMockContainer(id string, name string, image string, created time.Time) wt.Container { content := types.ContainerJSON{ ContainerJSONBase: &types.ContainerJSONBase{ ID: id, Image: image, Name: name, Created: created.String(), HostConfig: &dockerContainer.HostConfig{ PortBindings: map[nat.Port][]nat.PortBinding{}, }, }, Config: &dockerContainer.Config{ Image: image, Labels: make(map[string]string), ExposedPorts: map[nat.Port]struct{}{}, }, } return container.NewContainer( &content, CreateMockImageInfo(image), ) } // CreateMockImageInfo returns a mock image info struct based on the passed image func CreateMockImageInfo(image string) *types.ImageInspect { return &types.ImageInspect{ ID: image, RepoDigests: []string{ image, }, } } // CreateMockContainerWithImageInfo should only be used for testing func CreateMockContainerWithImageInfo(id string, name string, image string, created time.Time, imageInfo types.ImageInspect) wt.Container { return CreateMockContainerWithImageInfoP(id, name, image, created, &imageInfo) } // CreateMockContainerWithImageInfoP should only be used for testing func CreateMockContainerWithImageInfoP(id string, name string, image string, created time.Time, imageInfo *types.ImageInspect) wt.Container { content := types.ContainerJSON{ ContainerJSONBase: &types.ContainerJSONBase{ ID: id, Image: image, Name: name, Created: created.String(), }, Config: &dockerContainer.Config{ Image: image, Labels: make(map[string]string), }, } return container.NewContainer( &content, imageInfo, ) } // CreateMockContainerWithDigest should only be used for testing func CreateMockContainerWithDigest(id string, name string, image string, created time.Time, digest string) wt.Container { c := CreateMockContainer(id, name, image, created) c.ImageInfo().RepoDigests = []string{digest} return c } // CreateMockContainerWithConfig creates a container substitute valid for testing func CreateMockContainerWithConfig(id string, name string, image string, running bool, restarting bool, created time.Time, config *dockerContainer.Config) wt.Container { content := types.ContainerJSON{ ContainerJSONBase: &types.ContainerJSONBase{ ID: id, Image: image, Name: name, State: &types.ContainerState{ Running: running, Restarting: restarting, }, Created: created.String(), HostConfig: &dockerContainer.HostConfig{ PortBindings: map[nat.Port][]nat.PortBinding{}, }, }, Config: config, } return container.NewContainer( &content, CreateMockImageInfo(image), ) } // CreateContainerForProgress creates a container substitute for tracking session/update progress func CreateContainerForProgress(index int, idPrefix int, nameFormat string) (wt.Container, wt.ImageID) { indexStr := strconv.Itoa(idPrefix + index) mockID := indexStr + strings.Repeat("0", 61-len(indexStr)) contID := "c79" + mockID contName := fmt.Sprintf(nameFormat, index+1) oldImgID := "01d" + mockID newImgID := "d0a" + mockID imageName := fmt.Sprintf("mock/%s:latest", contName) config := &dockerContainer.Config{ Image: imageName, } c := CreateMockContainerWithConfig(contID, contName, oldImgID, true, false, time.Now(), config) return c, wt.ImageID(newImgID) } // CreateMockContainerWithLinks should only be used for testing func CreateMockContainerWithLinks(id string, name string, image string, created time.Time, links []string, imageInfo *types.ImageInspect) wt.Container { content := types.ContainerJSON{ ContainerJSONBase: &types.ContainerJSONBase{ ID: id, Image: image, Name: name, Created: created.String(), HostConfig: &dockerContainer.HostConfig{ Links: links, }, }, Config: &dockerContainer.Config{ Image: image, Labels: make(map[string]string), }, } return container.NewContainer( &content, imageInfo, ) } ================================================ FILE: internal/actions/mocks/progress.go ================================================ package mocks import ( "errors" "github.com/containrrr/watchtower/pkg/session" wt "github.com/containrrr/watchtower/pkg/types" ) // CreateMockProgressReport creates a mock report from a given set of container states // All containers will be given a unique ID and name based on its state and index func CreateMockProgressReport(states ...session.State) wt.Report { stateNums := make(map[session.State]int) progress := session.Progress{} failed := make(map[wt.ContainerID]error) for _, state := range states { index := stateNums[state] switch state { case session.SkippedState: c, _ := CreateContainerForProgress(index, 41, "skip%d") progress.AddSkipped(c, errors.New("unpossible")) case session.FreshState: c, _ := CreateContainerForProgress(index, 31, "frsh%d") progress.AddScanned(c, c.ImageID()) case session.UpdatedState: c, newImage := CreateContainerForProgress(index, 11, "updt%d") progress.AddScanned(c, newImage) progress.MarkForUpdate(c.ID()) case session.FailedState: c, newImage := CreateContainerForProgress(index, 21, "fail%d") progress.AddScanned(c, newImage) failed[c.ID()] = errors.New("accidentally the whole container") } stateNums[state] = index + 1 } progress.UpdateFailed(failed) return progress.Report() } ================================================ FILE: internal/actions/update.go ================================================ package actions import ( "errors" "github.com/containrrr/watchtower/internal/util" "github.com/containrrr/watchtower/pkg/container" "github.com/containrrr/watchtower/pkg/lifecycle" "github.com/containrrr/watchtower/pkg/session" "github.com/containrrr/watchtower/pkg/sorter" "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" ) // Update looks at the running Docker containers to see if any of the images // used to start those containers have been updated. If a change is detected in // any of the images, the associated containers are stopped and restarted with // the new image. func Update(client container.Client, params types.UpdateParams) (types.Report, error) { log.Debug("Checking containers for updated images") progress := &session.Progress{} staleCount := 0 if params.LifecycleHooks { lifecycle.ExecutePreChecks(client, params) } containers, err := client.ListContainers(params.Filter) if err != nil { return nil, err } staleCheckFailed := 0 for i, targetContainer := range containers { stale, newestImage, err := client.IsContainerStale(targetContainer, params) shouldUpdate := stale && !params.NoRestart && !targetContainer.IsMonitorOnly(params) if err == nil && shouldUpdate { // Check to make sure we have all the necessary information for recreating the container err = targetContainer.VerifyConfiguration() // If the image information is incomplete and trace logging is enabled, log it for further diagnosis if err != nil && log.IsLevelEnabled(log.TraceLevel) { imageInfo := targetContainer.ImageInfo() log.Tracef("Image info: %#v", imageInfo) log.Tracef("Container info: %#v", targetContainer.ContainerInfo()) if imageInfo != nil { log.Tracef("Image config: %#v", imageInfo.Config) } } } if err != nil { log.Infof("Unable to update container %q: %v. Proceeding to next.", targetContainer.Name(), err) stale = false staleCheckFailed++ progress.AddSkipped(targetContainer, err) } else { progress.AddScanned(targetContainer, newestImage) } containers[i].SetStale(stale) if stale { staleCount++ } } containers, err = sorter.SortByDependencies(containers) if err != nil { return nil, err } UpdateImplicitRestart(containers) var containersToUpdate []types.Container for _, c := range containers { if !c.IsMonitorOnly(params) { containersToUpdate = append(containersToUpdate, c) progress.MarkForUpdate(c.ID()) } } if params.RollingRestart { progress.UpdateFailed(performRollingRestart(containersToUpdate, client, params)) } else { failedStop, stoppedImages := stopContainersInReversedOrder(containersToUpdate, client, params) progress.UpdateFailed(failedStop) failedStart := restartContainersInSortedOrder(containersToUpdate, client, params, stoppedImages) progress.UpdateFailed(failedStart) } if params.LifecycleHooks { lifecycle.ExecutePostChecks(client, params) } return progress.Report(), nil } func performRollingRestart(containers []types.Container, client container.Client, params types.UpdateParams) map[types.ContainerID]error { cleanupImageIDs := make(map[types.ImageID]bool, len(containers)) failed := make(map[types.ContainerID]error, len(containers)) for i := len(containers) - 1; i >= 0; i-- { if containers[i].ToRestart() { err := stopStaleContainer(containers[i], client, params) if err != nil { failed[containers[i].ID()] = err } else { if err := restartStaleContainer(containers[i], client, params); err != nil { failed[containers[i].ID()] = err } else if containers[i].IsStale() { // Only add (previously) stale containers' images to cleanup cleanupImageIDs[containers[i].ImageID()] = true } } } } if params.Cleanup { cleanupImages(client, cleanupImageIDs) } return failed } func stopContainersInReversedOrder(containers []types.Container, client container.Client, params types.UpdateParams) (failed map[types.ContainerID]error, stopped map[types.ImageID]bool) { failed = make(map[types.ContainerID]error, len(containers)) stopped = make(map[types.ImageID]bool, len(containers)) for i := len(containers) - 1; i >= 0; i-- { if err := stopStaleContainer(containers[i], client, params); err != nil { failed[containers[i].ID()] = err } else { // NOTE: If a container is restarted due to a dependency this might be empty stopped[containers[i].SafeImageID()] = true } } return } func stopStaleContainer(container types.Container, client container.Client, params types.UpdateParams) error { if container.IsWatchtower() { log.Debugf("This is the watchtower container %s", container.Name()) return nil } if !container.ToRestart() { return nil } // Perform an additional check here to prevent us from stopping a linked container we cannot restart if container.IsLinkedToRestarting() { if err := container.VerifyConfiguration(); err != nil { return err } } if params.LifecycleHooks { skipUpdate, err := lifecycle.ExecutePreUpdateCommand(client, container) if err != nil { log.Error(err) log.Info("Skipping container as the pre-update command failed") return err } if skipUpdate { log.Debug("Skipping container as the pre-update command returned exit code 75 (EX_TEMPFAIL)") return errors.New("skipping container as the pre-update command returned exit code 75 (EX_TEMPFAIL)") } } if err := client.StopContainer(container, params.Timeout); err != nil { log.Error(err) return err } return nil } func restartContainersInSortedOrder(containers []types.Container, client container.Client, params types.UpdateParams, stoppedImages map[types.ImageID]bool) map[types.ContainerID]error { cleanupImageIDs := make(map[types.ImageID]bool, len(containers)) failed := make(map[types.ContainerID]error, len(containers)) for _, c := range containers { if !c.ToRestart() { continue } if stoppedImages[c.SafeImageID()] { if err := restartStaleContainer(c, client, params); err != nil { failed[c.ID()] = err } else if c.IsStale() { // Only add (previously) stale containers' images to cleanup cleanupImageIDs[c.ImageID()] = true } } } if params.Cleanup { cleanupImages(client, cleanupImageIDs) } return failed } func cleanupImages(client container.Client, imageIDs map[types.ImageID]bool) { for imageID := range imageIDs { if imageID == "" { continue } if err := client.RemoveImageByID(imageID); err != nil { log.Error(err) } } } func restartStaleContainer(container types.Container, client container.Client, params types.UpdateParams) error { // Since we can't shutdown a watchtower container immediately, we need to // start the new one while the old one is still running. This prevents us // from re-using the same container name so we first rename the current // instance so that the new one can adopt the old name. if container.IsWatchtower() { if err := client.RenameContainer(container, util.RandName()); err != nil { log.Error(err) return nil } } if !params.NoRestart { if newContainerID, err := client.StartContainer(container); err != nil { log.Error(err) return err } else if container.ToRestart() && params.LifecycleHooks { lifecycle.ExecutePostUpdateCommand(client, newContainerID) } } return nil } // UpdateImplicitRestart iterates through the passed containers, setting the // `LinkedToRestarting` flag if any of it's linked containers are marked for restart func UpdateImplicitRestart(containers []types.Container) { for ci, c := range containers { if c.ToRestart() { // The container is already marked for restart, no need to check continue } if link := linkedContainerMarkedForRestart(c.Links(), containers); link != "" { log.WithFields(log.Fields{ "restarting": link, "linked": c.Name(), }).Debug("container is linked to restarting") // NOTE: To mutate the array, the `c` variable cannot be used as it's a copy containers[ci].SetLinkedToRestarting(true) } } } // linkedContainerMarkedForRestart returns the name of the first link that matches a // container marked for restart func linkedContainerMarkedForRestart(links []string, containers []types.Container) string { for _, linkName := range links { for _, candidate := range containers { if candidate.Name() == linkName && candidate.ToRestart() { return linkName } } } return "" } ================================================ FILE: internal/actions/update_test.go ================================================ package actions_test import ( "time" "github.com/containrrr/watchtower/internal/actions" "github.com/containrrr/watchtower/pkg/types" dockerTypes "github.com/docker/docker/api/types" dockerContainer "github.com/docker/docker/api/types/container" "github.com/docker/go-connections/nat" . "github.com/containrrr/watchtower/internal/actions/mocks" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func getCommonTestData(keepContainer string) *TestData { return &TestData{ NameOfContainerToKeep: keepContainer, Containers: []types.Container{ CreateMockContainer( "test-container-01", "test-container-01", "fake-image:latest", time.Now().AddDate(0, 0, -1)), CreateMockContainer( "test-container-02", "test-container-02", "fake-image:latest", time.Now()), CreateMockContainer( "test-container-02", "test-container-02", "fake-image:latest", time.Now()), }, } } func getLinkedTestData(withImageInfo bool) *TestData { staleContainer := CreateMockContainer( "test-container-01", "/test-container-01", "fake-image1:latest", time.Now().AddDate(0, 0, -1)) var imageInfo *dockerTypes.ImageInspect if withImageInfo { imageInfo = CreateMockImageInfo("test-container-02") } linkingContainer := CreateMockContainerWithLinks( "test-container-02", "/test-container-02", "fake-image2:latest", time.Now(), []string{staleContainer.Name()}, imageInfo) return &TestData{ Staleness: map[string]bool{linkingContainer.Name(): false}, Containers: []types.Container{ staleContainer, linkingContainer, }, } } var _ = Describe("the update action", func() { When("watchtower has been instructed to clean up", func() { When("there are multiple containers using the same image", func() { It("should only try to remove the image once", func() { client := CreateMockClient(getCommonTestData(""), false, false) _, err := actions.Update(client, types.UpdateParams{Cleanup: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1)) }) }) When("there are multiple containers using different images", func() { It("should try to remove each of them", func() { testData := getCommonTestData("") testData.Containers = append( testData.Containers, CreateMockContainer( "unique-test-container", "unique-test-container", "unique-fake-image:latest", time.Now(), ), ) client := CreateMockClient(testData, false, false) _, err := actions.Update(client, types.UpdateParams{Cleanup: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(2)) }) }) When("there are linked containers being updated", func() { It("should not try to remove their images", func() { client := CreateMockClient(getLinkedTestData(true), false, false) _, err := actions.Update(client, types.UpdateParams{Cleanup: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1)) }) }) When("performing a rolling restart update", func() { It("should try to remove the image once", func() { client := CreateMockClient(getCommonTestData(""), false, false) _, err := actions.Update(client, types.UpdateParams{Cleanup: true, RollingRestart: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1)) }) }) When("updating a linked container with missing image info", func() { It("should gracefully fail", func() { client := CreateMockClient(getLinkedTestData(false), false, false) report, err := actions.Update(client, types.UpdateParams{}) Expect(err).NotTo(HaveOccurred()) // Note: Linked containers that were skipped for recreation is not counted in Failed // If this happens, an error is emitted to the logs, so a notification should still be sent. Expect(report.Updated()).To(HaveLen(1)) Expect(report.Fresh()).To(HaveLen(1)) }) }) }) When("watchtower has been instructed to monitor only", func() { When("certain containers are set to monitor only", func() { It("should not update those containers", func() { client := CreateMockClient( &TestData{ NameOfContainerToKeep: "test-container-02", Containers: []types.Container{ CreateMockContainer( "test-container-01", "test-container-01", "fake-image1:latest", time.Now()), CreateMockContainerWithConfig( "test-container-02", "test-container-02", "fake-image2:latest", false, false, time.Now(), &dockerContainer.Config{ Labels: map[string]string{ "com.centurylinklabs.watchtower.monitor-only": "true", }, }), }, }, false, false, ) _, err := actions.Update(client, types.UpdateParams{Cleanup: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1)) }) }) When("monitor only is set globally", func() { It("should not update any containers", func() { client := CreateMockClient( &TestData{ Containers: []types.Container{ CreateMockContainer( "test-container-01", "test-container-01", "fake-image:latest", time.Now()), CreateMockContainer( "test-container-02", "test-container-02", "fake-image:latest", time.Now()), }, }, false, false, ) _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0)) }) When("watchtower has been instructed to have label take precedence", func() { It("it should update containers when monitor only is set to false", func() { client := CreateMockClient( &TestData{ //NameOfContainerToKeep: "test-container-02", Containers: []types.Container{ CreateMockContainerWithConfig( "test-container-02", "test-container-02", "fake-image2:latest", false, false, time.Now(), &dockerContainer.Config{ Labels: map[string]string{ "com.centurylinklabs.watchtower.monitor-only": "false", }, }), }, }, false, false, ) _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true, LabelPrecedence: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1)) }) It("it should update not containers when monitor only is set to true", func() { client := CreateMockClient( &TestData{ //NameOfContainerToKeep: "test-container-02", Containers: []types.Container{ CreateMockContainerWithConfig( "test-container-02", "test-container-02", "fake-image2:latest", false, false, time.Now(), &dockerContainer.Config{ Labels: map[string]string{ "com.centurylinklabs.watchtower.monitor-only": "true", }, }), }, }, false, false, ) _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true, LabelPrecedence: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0)) }) It("it should update not containers when monitor only is not set", func() { client := CreateMockClient( &TestData{ Containers: []types.Container{ CreateMockContainer( "test-container-01", "test-container-01", "fake-image:latest", time.Now()), }, }, false, false, ) _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true, LabelPrecedence: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0)) }) }) }) }) When("watchtower has been instructed to run lifecycle hooks", func() { When("pre-update script returns 1", func() { It("should not update those containers", func() { client := CreateMockClient( &TestData{ //NameOfContainerToKeep: "test-container-02", Containers: []types.Container{ CreateMockContainerWithConfig( "test-container-02", "test-container-02", "fake-image2:latest", true, false, time.Now(), &dockerContainer.Config{ Labels: map[string]string{ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190", "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn1.sh", }, ExposedPorts: map[nat.Port]struct{}{}, }), }, }, false, false, ) _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0)) }) }) When("prupddate script returns 75", func() { It("should not update those containers", func() { client := CreateMockClient( &TestData{ //NameOfContainerToKeep: "test-container-02", Containers: []types.Container{ CreateMockContainerWithConfig( "test-container-02", "test-container-02", "fake-image2:latest", true, false, time.Now(), &dockerContainer.Config{ Labels: map[string]string{ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190", "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn75.sh", }, ExposedPorts: map[nat.Port]struct{}{}, }), }, }, false, false, ) _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0)) }) }) When("prupddate script returns 0", func() { It("should update those containers", func() { client := CreateMockClient( &TestData{ //NameOfContainerToKeep: "test-container-02", Containers: []types.Container{ CreateMockContainerWithConfig( "test-container-02", "test-container-02", "fake-image2:latest", true, false, time.Now(), &dockerContainer.Config{ Labels: map[string]string{ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190", "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn0.sh", }, ExposedPorts: map[nat.Port]struct{}{}, }), }, }, false, false, ) _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1)) }) }) When("container is linked to restarting containers", func() { It("should be marked for restart", func() { provider := CreateMockContainerWithConfig( "test-container-provider", "/test-container-provider", "fake-image2:latest", true, false, time.Now(), &dockerContainer.Config{ Labels: map[string]string{}, ExposedPorts: map[nat.Port]struct{}{}, }) provider.SetStale(true) consumer := CreateMockContainerWithConfig( "test-container-consumer", "/test-container-consumer", "fake-image3:latest", true, false, time.Now(), &dockerContainer.Config{ Labels: map[string]string{ "com.centurylinklabs.watchtower.depends-on": "test-container-provider", }, ExposedPorts: map[nat.Port]struct{}{}, }) containers := []types.Container{ provider, consumer, } Expect(provider.ToRestart()).To(BeTrue()) Expect(consumer.ToRestart()).To(BeFalse()) actions.UpdateImplicitRestart(containers) Expect(containers[0].ToRestart()).To(BeTrue()) Expect(containers[1].ToRestart()).To(BeTrue()) }) }) When("container is not running", func() { It("skip running preupdate", func() { client := CreateMockClient( &TestData{ //NameOfContainerToKeep: "test-container-02", Containers: []types.Container{ CreateMockContainerWithConfig( "test-container-02", "test-container-02", "fake-image2:latest", false, false, time.Now(), &dockerContainer.Config{ Labels: map[string]string{ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190", "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn1.sh", }, ExposedPorts: map[nat.Port]struct{}{}, }), }, }, false, false, ) _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1)) }) }) When("container is restarting", func() { It("skip running preupdate", func() { client := CreateMockClient( &TestData{ //NameOfContainerToKeep: "test-container-02", Containers: []types.Container{ CreateMockContainerWithConfig( "test-container-02", "test-container-02", "fake-image2:latest", false, true, time.Now(), &dockerContainer.Config{ Labels: map[string]string{ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190", "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn1.sh", }, ExposedPorts: map[nat.Port]struct{}{}, }), }, }, false, false, ) _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true}) Expect(err).NotTo(HaveOccurred()) Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1)) }) }) }) }) ================================================ FILE: internal/flags/flags.go ================================================ package flags import ( "bufio" "errors" "fmt" "os" "regexp" "strings" "time" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" ) // DockerAPIMinVersion is the minimum version of the docker api required to // use watchtower const DockerAPIMinVersion string = "1.25" var defaultInterval = int((time.Hour * 24).Seconds()) // RegisterDockerFlags that are used directly by the docker api client func RegisterDockerFlags(rootCmd *cobra.Command) { flags := rootCmd.PersistentFlags() flags.StringP("host", "H", envString("DOCKER_HOST"), "daemon socket to connect to") flags.BoolP("tlsverify", "v", envBool("DOCKER_TLS_VERIFY"), "use TLS and verify the remote") flags.StringP("api-version", "a", envString("DOCKER_API_VERSION"), "api version to use by docker client") } // RegisterSystemFlags that are used by watchtower to modify the program flow func RegisterSystemFlags(rootCmd *cobra.Command) { flags := rootCmd.PersistentFlags() flags.IntP( "interval", "i", envInt("WATCHTOWER_POLL_INTERVAL"), "Poll interval (in seconds)") flags.StringP( "schedule", "s", envString("WATCHTOWER_SCHEDULE"), "The cron expression which defines when to update") flags.DurationP( "stop-timeout", "t", envDuration("WATCHTOWER_TIMEOUT"), "Timeout before a container is forcefully stopped") flags.BoolP( "no-pull", "", envBool("WATCHTOWER_NO_PULL"), "Do not pull any new images") flags.BoolP( "no-restart", "", envBool("WATCHTOWER_NO_RESTART"), "Do not restart any containers") flags.BoolP( "no-startup-message", "", envBool("WATCHTOWER_NO_STARTUP_MESSAGE"), "Prevents watchtower from sending a startup message") flags.BoolP( "cleanup", "c", envBool("WATCHTOWER_CLEANUP"), "Remove previously used images after updating") flags.BoolP( "remove-volumes", "", envBool("WATCHTOWER_REMOVE_VOLUMES"), "Remove attached volumes before updating") flags.BoolP( "label-enable", "e", envBool("WATCHTOWER_LABEL_ENABLE"), "Watch containers where the com.centurylinklabs.watchtower.enable label is true") flags.StringSliceP( "disable-containers", "x", // Due to issue spf13/viper#380, can't use viper.GetStringSlice: regexp.MustCompile("[, ]+").Split(envString("WATCHTOWER_DISABLE_CONTAINERS"), -1), "Comma-separated list of containers to explicitly exclude from watching.") flags.StringP( "log-format", "l", viper.GetString("WATCHTOWER_LOG_FORMAT"), "Sets what logging format to use for console output. Possible values: Auto, LogFmt, Pretty, JSON") flags.BoolP( "debug", "d", envBool("WATCHTOWER_DEBUG"), "Enable debug mode with verbose logging") flags.BoolP( "trace", "", envBool("WATCHTOWER_TRACE"), "Enable trace mode with very verbose logging - caution, exposes credentials") flags.BoolP( "monitor-only", "m", envBool("WATCHTOWER_MONITOR_ONLY"), "Will only monitor for new images, not update the containers") flags.BoolP( "run-once", "R", envBool("WATCHTOWER_RUN_ONCE"), "Run once now and exit") flags.BoolP( "include-restarting", "", envBool("WATCHTOWER_INCLUDE_RESTARTING"), "Will also include restarting containers") flags.BoolP( "include-stopped", "S", envBool("WATCHTOWER_INCLUDE_STOPPED"), "Will also include created and exited containers") flags.BoolP( "revive-stopped", "", envBool("WATCHTOWER_REVIVE_STOPPED"), "Will also start stopped containers that were updated, if include-stopped is active") flags.BoolP( "enable-lifecycle-hooks", "", envBool("WATCHTOWER_LIFECYCLE_HOOKS"), "Enable the execution of commands triggered by pre- and post-update lifecycle hooks") flags.BoolP( "rolling-restart", "", envBool("WATCHTOWER_ROLLING_RESTART"), "Restart containers one at a time") flags.BoolP( "http-api-update", "", envBool("WATCHTOWER_HTTP_API_UPDATE"), "Runs Watchtower in HTTP API mode, so that image updates must to be triggered by a request") flags.BoolP( "http-api-metrics", "", envBool("WATCHTOWER_HTTP_API_METRICS"), "Runs Watchtower with the Prometheus metrics API enabled") flags.StringP( "http-api-token", "", envString("WATCHTOWER_HTTP_API_TOKEN"), "Sets an authentication token to HTTP API requests.") flags.BoolP( "http-api-periodic-polls", "", envBool("WATCHTOWER_HTTP_API_PERIODIC_POLLS"), "Also run periodic updates (specified with --interval and --schedule) if HTTP API is enabled") // https://no-color.org/ flags.BoolP( "no-color", "", viper.IsSet("NO_COLOR"), "Disable ANSI color escape codes in log output") flags.StringP( "scope", "", envString("WATCHTOWER_SCOPE"), "Defines a monitoring scope for the Watchtower instance.") flags.StringP( "porcelain", "P", envString("WATCHTOWER_PORCELAIN"), `Write session results to stdout using a stable versioned format. Supported values: "v1"`) flags.String( "log-level", envString("WATCHTOWER_LOG_LEVEL"), "The maximum log level that will be written to STDERR. Possible values: panic, fatal, error, warn, info, debug or trace") flags.BoolP( "health-check", "", false, "Do health check and exit") flags.BoolP( "label-take-precedence", "", envBool("WATCHTOWER_LABEL_TAKE_PRECEDENCE"), "Label applied to containers take precedence over arguments") } // RegisterNotificationFlags that are used by watchtower to send notifications func RegisterNotificationFlags(rootCmd *cobra.Command) { flags := rootCmd.PersistentFlags() flags.StringSliceP( "notifications", "n", envStringSlice("WATCHTOWER_NOTIFICATIONS"), " Notification types to send (valid: email, slack, msteams, gotify, shoutrrr)") flags.String( "notifications-level", envString("WATCHTOWER_NOTIFICATIONS_LEVEL"), "The log level used for sending notifications. Possible values: panic, fatal, error, warn, info or debug") flags.IntP( "notifications-delay", "", envInt("WATCHTOWER_NOTIFICATIONS_DELAY"), "Delay before sending notifications, expressed in seconds") flags.StringP( "notifications-hostname", "", envString("WATCHTOWER_NOTIFICATIONS_HOSTNAME"), "Custom hostname for notification titles") flags.StringP( "notification-email-from", "", envString("WATCHTOWER_NOTIFICATION_EMAIL_FROM"), "Address to send notification emails from") flags.StringP( "notification-email-to", "", envString("WATCHTOWER_NOTIFICATION_EMAIL_TO"), "Address to send notification emails to") flags.IntP( "notification-email-delay", "", envInt("WATCHTOWER_NOTIFICATION_EMAIL_DELAY"), "Delay before sending notifications, expressed in seconds") flags.StringP( "notification-email-server", "", envString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER"), "SMTP server to send notification emails through") flags.IntP( "notification-email-server-port", "", envInt("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT"), "SMTP server port to send notification emails through") flags.BoolP( "notification-email-server-tls-skip-verify", "", envBool("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY"), `Controls whether watchtower verifies the SMTP server's certificate chain and host name. Should only be used for testing.`) flags.StringP( "notification-email-server-user", "", envString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER"), "SMTP server user for sending notifications") flags.StringP( "notification-email-server-password", "", envString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD"), "SMTP server password for sending notifications") flags.StringP( "notification-email-subjecttag", "", envString("WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG"), "Subject prefix tag for notifications via mail") flags.StringP( "notification-slack-hook-url", "", envString("WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL"), "The Slack Hook URL to send notifications to") flags.StringP( "notification-slack-identifier", "", envString("WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER"), "A string which will be used to identify the messages coming from this watchtower instance") flags.StringP( "notification-slack-channel", "", envString("WATCHTOWER_NOTIFICATION_SLACK_CHANNEL"), "A string which overrides the webhook's default channel. Example: #my-custom-channel") flags.StringP( "notification-slack-icon-emoji", "", envString("WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI"), "An emoji code string to use in place of the default icon") flags.StringP( "notification-slack-icon-url", "", envString("WATCHTOWER_NOTIFICATION_SLACK_ICON_URL"), "An icon image URL string to use in place of the default icon") flags.StringP( "notification-msteams-hook", "", envString("WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL"), "The MSTeams WebHook URL to send notifications to") flags.BoolP( "notification-msteams-data", "", envBool("WATCHTOWER_NOTIFICATION_MSTEAMS_USE_LOG_DATA"), "The MSTeams notifier will try to extract log entry fields as MSTeams message facts") flags.StringP( "notification-gotify-url", "", envString("WATCHTOWER_NOTIFICATION_GOTIFY_URL"), "The Gotify URL to send notifications to") flags.StringP( "notification-gotify-token", "", envString("WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN"), "The Gotify Application required to query the Gotify API") flags.BoolP( "notification-gotify-tls-skip-verify", "", envBool("WATCHTOWER_NOTIFICATION_GOTIFY_TLS_SKIP_VERIFY"), `Controls whether watchtower verifies the Gotify server's certificate chain and host name. Should only be used for testing.`) flags.String( "notification-template", envString("WATCHTOWER_NOTIFICATION_TEMPLATE"), "The shoutrrr text/template for the messages") flags.StringArray( "notification-url", envStringSlice("WATCHTOWER_NOTIFICATION_URL"), "The shoutrrr URL to send notifications to") flags.Bool("notification-report", envBool("WATCHTOWER_NOTIFICATION_REPORT"), "Use the session report as the notification template data") flags.StringP( "notification-title-tag", "", envString("WATCHTOWER_NOTIFICATION_TITLE_TAG"), "Title prefix tag for notifications") flags.Bool("notification-skip-title", envBool("WATCHTOWER_NOTIFICATION_SKIP_TITLE"), "Do not pass the title param to notifications") flags.String( "warn-on-head-failure", envString("WATCHTOWER_WARN_ON_HEAD_FAILURE"), "When to warn about HEAD pull requests failing. Possible values: always, auto or never") flags.Bool( "notification-log-stdout", envBool("WATCHTOWER_NOTIFICATION_LOG_STDOUT"), "Write notification logs to stdout instead of logging (to stderr)") } func envString(key string) string { viper.MustBindEnv(key) return viper.GetString(key) } func envStringSlice(key string) []string { viper.MustBindEnv(key) return viper.GetStringSlice(key) } func envInt(key string) int { viper.MustBindEnv(key) return viper.GetInt(key) } func envBool(key string) bool { viper.MustBindEnv(key) return viper.GetBool(key) } func envDuration(key string) time.Duration { viper.MustBindEnv(key) return viper.GetDuration(key) } // SetDefaults provides default values for environment variables func SetDefaults() { viper.AutomaticEnv() viper.SetDefault("DOCKER_HOST", "unix:///var/run/docker.sock") viper.SetDefault("DOCKER_API_VERSION", DockerAPIMinVersion) viper.SetDefault("WATCHTOWER_POLL_INTERVAL", defaultInterval) viper.SetDefault("WATCHTOWER_TIMEOUT", time.Second*10) viper.SetDefault("WATCHTOWER_NOTIFICATIONS", []string{}) viper.SetDefault("WATCHTOWER_NOTIFICATIONS_LEVEL", "info") viper.SetDefault("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT", 25) viper.SetDefault("WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG", "") viper.SetDefault("WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER", "watchtower") viper.SetDefault("WATCHTOWER_LOG_LEVEL", "info") viper.SetDefault("WATCHTOWER_LOG_FORMAT", "auto") } // EnvConfig translates the command-line options into environment variables // that will initialize the api client func EnvConfig(cmd *cobra.Command) error { var err error var host string var tls bool var version string flags := cmd.PersistentFlags() if host, err = flags.GetString("host"); err != nil { return err } if tls, err = flags.GetBool("tlsverify"); err != nil { return err } if version, err = flags.GetString("api-version"); err != nil { return err } if err = setEnvOptStr("DOCKER_HOST", host); err != nil { return err } if err = setEnvOptBool("DOCKER_TLS_VERIFY", tls); err != nil { return err } if err = setEnvOptStr("DOCKER_API_VERSION", version); err != nil { return err } return nil } // ReadFlags reads common flags used in the main program flow of watchtower func ReadFlags(cmd *cobra.Command) (bool, bool, bool, time.Duration) { flags := cmd.PersistentFlags() var err error var cleanup bool var noRestart bool var monitorOnly bool var timeout time.Duration if cleanup, err = flags.GetBool("cleanup"); err != nil { log.Fatal(err) } if noRestart, err = flags.GetBool("no-restart"); err != nil { log.Fatal(err) } if monitorOnly, err = flags.GetBool("monitor-only"); err != nil { log.Fatal(err) } if timeout, err = flags.GetDuration("stop-timeout"); err != nil { log.Fatal(err) } return cleanup, noRestart, monitorOnly, timeout } func setEnvOptStr(env string, opt string) error { if opt == "" || opt == os.Getenv(env) { return nil } err := os.Setenv(env, opt) if err != nil { return err } return nil } func setEnvOptBool(env string, opt bool) error { if opt { return setEnvOptStr(env, "1") } return nil } // GetSecretsFromFiles checks if passwords/tokens/webhooks have been passed as a file instead of plaintext. // If so, the value of the flag will be replaced with the contents of the file. func GetSecretsFromFiles(rootCmd *cobra.Command) { flags := rootCmd.PersistentFlags() secrets := []string{ "notification-email-server-password", "notification-slack-hook-url", "notification-msteams-hook", "notification-gotify-token", "notification-url", "http-api-token", } for _, secret := range secrets { if err := getSecretFromFile(flags, secret); err != nil { log.Fatalf("failed to get secret from flag %v: %s", secret, err) } } } // getSecretFromFile will check if the flag contains a reference to a file; if it does, replaces the value of the flag with the contents of the file. func getSecretFromFile(flags *pflag.FlagSet, secret string) error { flag := flags.Lookup(secret) if sliceValue, ok := flag.Value.(pflag.SliceValue); ok { oldValues := sliceValue.GetSlice() values := make([]string, 0, len(oldValues)) for _, value := range oldValues { if value != "" && isFile(value) { file, err := os.Open(value) if err != nil { return err } scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if line == "" { continue } values = append(values, line) } if err := file.Close(); err != nil { return err } } else { values = append(values, value) } } return sliceValue.Replace(values) } value := flag.Value.String() if value != "" && isFile(value) { content, err := os.ReadFile(value) if err != nil { return err } return flags.Set(secret, strings.TrimSpace(string(content))) } return nil } func isFile(s string) bool { firstColon := strings.IndexRune(s, ':') if firstColon != 1 && firstColon != -1 { // If the string contains a ':', but it's not the second character, it's probably not a file // and will cause a fatal error on windows if stat'ed // This still allows for paths that start with 'c:\' etc. return false } _, err := os.Stat(s) return !errors.Is(err, os.ErrNotExist) } // ProcessFlagAliases updates the value of flags that are being set by helper flags func ProcessFlagAliases(flags *pflag.FlagSet) { porcelain, err := flags.GetString(`porcelain`) if err != nil { log.Fatalf(`Failed to get flag: %v`, err) } if porcelain != "" { if porcelain != "v1" { log.Fatalf(`Unknown porcelain version %q. Supported values: "v1"`, porcelain) } if err = appendFlagValue(flags, `notification-url`, `logger://`); err != nil { log.Errorf(`Failed to set flag: %v`, err) } setFlagIfDefault(flags, `notification-log-stdout`, `true`) setFlagIfDefault(flags, `notification-report`, `true`) tpl := fmt.Sprintf(`porcelain.%s.summary-no-log`, porcelain) setFlagIfDefault(flags, `notification-template`, tpl) } scheduleChanged := flags.Changed(`schedule`) intervalChanged := flags.Changed(`interval`) // FIXME: snakeswap // due to how viper is integrated by swapping the defaults for the flags, we need this hack: if val, _ := flags.GetString(`schedule`); val != `` { scheduleChanged = true } if val, _ := flags.GetInt(`interval`); val != defaultInterval { intervalChanged = true } if intervalChanged && scheduleChanged { log.Fatal(`Only schedule or interval can be defined, not both.`) } // update schedule flag to match interval if it's set, or to the default if none of them are if intervalChanged || !scheduleChanged { interval, _ := flags.GetInt(`interval`) _ = flags.Set(`schedule`, fmt.Sprintf(`@every %ds`, interval)) } if flagIsEnabled(flags, `debug`) { _ = flags.Set(`log-level`, `debug`) } if flagIsEnabled(flags, `trace`) { _ = flags.Set(`log-level`, `trace`) } } // SetupLogging reads only the flags that is needed to set up logging and applies them to the global logger func SetupLogging(f *pflag.FlagSet) error { logFormat, _ := f.GetString(`log-format`) noColor, _ := f.GetBool("no-color") switch strings.ToLower(logFormat) { case "auto": // This will either use the "pretty" or "logfmt" format, based on whether the standard out is connected to a TTY log.SetFormatter(&log.TextFormatter{ DisableColors: noColor, // enable logrus built-in support for https://bixense.com/clicolors/ EnvironmentOverrideColors: true, }) case "json": log.SetFormatter(&log.JSONFormatter{}) case "logfmt": log.SetFormatter(&log.TextFormatter{ DisableColors: true, FullTimestamp: true, }) case "pretty": log.SetFormatter(&log.TextFormatter{ // "Pretty" format combined with `--no-color` will only change the timestamp to the time since start ForceColors: !noColor, FullTimestamp: false, }) default: return fmt.Errorf("invalid log format: %s", logFormat) } rawLogLevel, _ := f.GetString(`log-level`) if logLevel, err := log.ParseLevel(rawLogLevel); err != nil { return fmt.Errorf("invalid log level: %e", err) } else { log.SetLevel(logLevel) } return nil } func flagIsEnabled(flags *pflag.FlagSet, name string) bool { value, err := flags.GetBool(name) if err != nil { log.Fatalf(`The flag %q is not defined`, name) } return value } func appendFlagValue(flags *pflag.FlagSet, name string, values ...string) error { flag := flags.Lookup(name) if flag == nil { return fmt.Errorf(`invalid flag name %q`, name) } if flagValues, ok := flag.Value.(pflag.SliceValue); ok { for _, value := range values { _ = flagValues.Append(value) } } else { return fmt.Errorf(`the value for flag %q is not a slice value`, name) } return nil } func setFlagIfDefault(flags *pflag.FlagSet, name string, value string) { if flags.Changed(name) { return } if err := flags.Set(name, value); err != nil { log.Errorf(`Failed to set flag: %v`, err) } } ================================================ FILE: internal/flags/flags_test.go ================================================ package flags import ( "os" "strings" "testing" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEnvConfig_Defaults(t *testing.T) { // Unset testing environments own variables, since those are not what is under test _ = os.Unsetenv("DOCKER_TLS_VERIFY") _ = os.Unsetenv("DOCKER_HOST") cmd := new(cobra.Command) SetDefaults() RegisterDockerFlags(cmd) err := EnvConfig(cmd) require.NoError(t, err) assert.Equal(t, "unix:///var/run/docker.sock", os.Getenv("DOCKER_HOST")) assert.Equal(t, "", os.Getenv("DOCKER_TLS_VERIFY")) // Re-enable this test when we've moved to github actions. // assert.Equal(t, DockerAPIMinVersion, os.Getenv("DOCKER_API_VERSION")) } func TestEnvConfig_Custom(t *testing.T) { cmd := new(cobra.Command) SetDefaults() RegisterDockerFlags(cmd) err := cmd.ParseFlags([]string{"--host", "some-custom-docker-host", "--tlsverify", "--api-version", "1.99"}) require.NoError(t, err) err = EnvConfig(cmd) require.NoError(t, err) assert.Equal(t, "some-custom-docker-host", os.Getenv("DOCKER_HOST")) assert.Equal(t, "1", os.Getenv("DOCKER_TLS_VERIFY")) // Re-enable this test when we've moved to github actions. // assert.Equal(t, "1.99", os.Getenv("DOCKER_API_VERSION")) } func TestGetSecretsFromFilesWithString(t *testing.T) { value := "supersecretstring" t.Setenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", value) testGetSecretsFromFiles(t, "notification-email-server-password", value) } func TestGetSecretsFromFilesWithFile(t *testing.T) { value := "megasecretstring" // Create the temporary file which will contain a secret. file, err := os.CreateTemp(t.TempDir(), "watchtower-") require.NoError(t, err) // Write the secret to the temporary file. _, err = file.Write([]byte(value)) require.NoError(t, err) require.NoError(t, file.Close()) t.Setenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", file.Name()) testGetSecretsFromFiles(t, "notification-email-server-password", value) } func TestGetSliceSecretsFromFiles(t *testing.T) { values := []string{"entry2", "", "entry3"} // Create the temporary file which will contain a secret. file, err := os.CreateTemp(t.TempDir(), "watchtower-") require.NoError(t, err) // Write the secret to the temporary file. for _, value := range values { _, err = file.WriteString("\n" + value) require.NoError(t, err) } require.NoError(t, file.Close()) testGetSecretsFromFiles(t, "notification-url", `[entry1,entry2,entry3]`, `--notification-url`, "entry1", `--notification-url`, file.Name()) } func testGetSecretsFromFiles(t *testing.T, flagName string, expected string, args ...string) { cmd := new(cobra.Command) SetDefaults() RegisterSystemFlags(cmd) RegisterNotificationFlags(cmd) require.NoError(t, cmd.ParseFlags(args)) GetSecretsFromFiles(cmd) flag := cmd.PersistentFlags().Lookup(flagName) require.NotNil(t, flag) value := flag.Value.String() assert.Equal(t, expected, value) } func TestHTTPAPIPeriodicPollsFlag(t *testing.T) { cmd := new(cobra.Command) SetDefaults() RegisterDockerFlags(cmd) RegisterSystemFlags(cmd) err := cmd.ParseFlags([]string{"--http-api-periodic-polls"}) require.NoError(t, err) periodicPolls, err := cmd.PersistentFlags().GetBool("http-api-periodic-polls") require.NoError(t, err) assert.Equal(t, true, periodicPolls) } func TestIsFile(t *testing.T) { assert.False(t, isFile("https://google.com"), "an URL should never be considered a file") assert.True(t, isFile(os.Args[0]), "the currently running binary path should always be considered a file") } func TestProcessFlagAliases(t *testing.T) { logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() } cmd := new(cobra.Command) SetDefaults() RegisterDockerFlags(cmd) RegisterSystemFlags(cmd) RegisterNotificationFlags(cmd) require.NoError(t, cmd.ParseFlags([]string{ `--porcelain`, `v1`, `--interval`, `10`, `--trace`, })) flags := cmd.Flags() ProcessFlagAliases(flags) urls, _ := flags.GetStringArray(`notification-url`) assert.Contains(t, urls, `logger://`) logStdout, _ := flags.GetBool(`notification-log-stdout`) assert.True(t, logStdout) report, _ := flags.GetBool(`notification-report`) assert.True(t, report) template, _ := flags.GetString(`notification-template`) assert.Equal(t, `porcelain.v1.summary-no-log`, template) sched, _ := flags.GetString(`schedule`) assert.Equal(t, `@every 10s`, sched) logLevel, _ := flags.GetString(`log-level`) assert.Equal(t, `trace`, logLevel) } func TestProcessFlagAliasesLogLevelFromEnvironment(t *testing.T) { cmd := new(cobra.Command) t.Setenv("WATCHTOWER_DEBUG", `true`) SetDefaults() RegisterDockerFlags(cmd) RegisterSystemFlags(cmd) RegisterNotificationFlags(cmd) require.NoError(t, cmd.ParseFlags([]string{})) flags := cmd.Flags() ProcessFlagAliases(flags) logLevel, _ := flags.GetString(`log-level`) assert.Equal(t, `debug`, logLevel) } func TestLogFormatFlag(t *testing.T) { cmd := new(cobra.Command) SetDefaults() RegisterDockerFlags(cmd) RegisterSystemFlags(cmd) // Ensure the default value is Auto require.NoError(t, cmd.ParseFlags([]string{})) require.NoError(t, SetupLogging(cmd.Flags())) assert.IsType(t, &logrus.TextFormatter{}, logrus.StandardLogger().Formatter) // Test JSON format require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `JSON`})) require.NoError(t, SetupLogging(cmd.Flags())) assert.IsType(t, &logrus.JSONFormatter{}, logrus.StandardLogger().Formatter) // Test Pretty format require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `pretty`})) require.NoError(t, SetupLogging(cmd.Flags())) assert.IsType(t, &logrus.TextFormatter{}, logrus.StandardLogger().Formatter) textFormatter, ok := (logrus.StandardLogger().Formatter).(*logrus.TextFormatter) assert.True(t, ok) assert.True(t, textFormatter.ForceColors) assert.False(t, textFormatter.FullTimestamp) // Test LogFmt format require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `logfmt`})) require.NoError(t, SetupLogging(cmd.Flags())) textFormatter, ok = (logrus.StandardLogger().Formatter).(*logrus.TextFormatter) assert.True(t, ok) assert.True(t, textFormatter.DisableColors) assert.True(t, textFormatter.FullTimestamp) // Test invalid format require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `cowsay`})) require.Error(t, SetupLogging(cmd.Flags())) } func TestLogLevelFlag(t *testing.T) { cmd := new(cobra.Command) SetDefaults() RegisterDockerFlags(cmd) RegisterSystemFlags(cmd) // Test invalid format require.NoError(t, cmd.ParseFlags([]string{`--log-level`, `gossip`})) require.Error(t, SetupLogging(cmd.Flags())) } func TestProcessFlagAliasesSchedAndInterval(t *testing.T) { logrus.StandardLogger().ExitFunc = func(_ int) { panic(`FATAL`) } cmd := new(cobra.Command) SetDefaults() RegisterDockerFlags(cmd) RegisterSystemFlags(cmd) RegisterNotificationFlags(cmd) require.NoError(t, cmd.ParseFlags([]string{`--schedule`, `@hourly`, `--interval`, `10`})) flags := cmd.Flags() assert.PanicsWithValue(t, `FATAL`, func() { ProcessFlagAliases(flags) }) } func TestProcessFlagAliasesScheduleFromEnvironment(t *testing.T) { cmd := new(cobra.Command) t.Setenv("WATCHTOWER_SCHEDULE", `@hourly`) SetDefaults() RegisterDockerFlags(cmd) RegisterSystemFlags(cmd) RegisterNotificationFlags(cmd) require.NoError(t, cmd.ParseFlags([]string{})) flags := cmd.Flags() ProcessFlagAliases(flags) sched, _ := flags.GetString(`schedule`) assert.Equal(t, `@hourly`, sched) } func TestProcessFlagAliasesInvalidPorcelaineVersion(t *testing.T) { logrus.StandardLogger().ExitFunc = func(_ int) { panic(`FATAL`) } cmd := new(cobra.Command) SetDefaults() RegisterDockerFlags(cmd) RegisterSystemFlags(cmd) RegisterNotificationFlags(cmd) require.NoError(t, cmd.ParseFlags([]string{`--porcelain`, `cowboy`})) flags := cmd.Flags() assert.PanicsWithValue(t, `FATAL`, func() { ProcessFlagAliases(flags) }) } func TestFlagsArePrecentInDocumentation(t *testing.T) { // Legacy notifcations are ignored, since they are (soft) deprecated ignoredEnvs := map[string]string{ "WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI": "legacy", "WATCHTOWER_NOTIFICATION_SLACK_ICON_URL": "legacy", } ignoredFlags := map[string]string{ "notification-gotify-url": "legacy", "notification-slack-icon-emoji": "legacy", "notification-slack-icon-url": "legacy", } cmd := new(cobra.Command) SetDefaults() RegisterDockerFlags(cmd) RegisterSystemFlags(cmd) RegisterNotificationFlags(cmd) flags := cmd.PersistentFlags() docFiles := []string{ "../../docs/arguments.md", "../../docs/lifecycle-hooks.md", "../../docs/notifications.md", } allDocs := "" for _, f := range docFiles { bytes, err := os.ReadFile(f) if err != nil { t.Fatalf("Could not load docs file %q: %v", f, err) } allDocs += string(bytes) } flags.VisitAll(func(f *pflag.Flag) { if !strings.Contains(allDocs, "--"+f.Name) { if _, found := ignoredFlags[f.Name]; !found { t.Logf("Docs does not mention flag long name %q", f.Name) t.Fail() } } if !strings.Contains(allDocs, "-"+f.Shorthand) { t.Logf("Docs does not mention flag shorthand %q (%q)", f.Shorthand, f.Name) t.Fail() } }) for _, key := range viper.AllKeys() { envKey := strings.ToUpper(key) if !strings.Contains(allDocs, envKey) { if _, found := ignoredEnvs[envKey]; !found { t.Logf("Docs does not mention environment variable %q", envKey) t.Fail() } } } } ================================================ FILE: internal/meta/meta.go ================================================ package meta var ( // Version is the compile-time set version of Watchtower Version = "v0.0.0-unknown" // UserAgent is the http client identifier derived from Version UserAgent string ) func init() { UserAgent = "Watchtower/" + Version } ================================================ FILE: internal/util/rand_name.go ================================================ package util import "math/rand" var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") // RandName Generates a random, 32-character, Docker-compatible container name. func RandName() string { b := make([]rune, 32) for i := range b { b[i] = letters[rand.Intn(len(letters))] } return string(b) } ================================================ FILE: internal/util/rand_sha256.go ================================================ package util import ( "bytes" "crypto/rand" "fmt" ) // GenerateRandomSHA256 generates a random 64 character SHA 256 hash string func GenerateRandomSHA256() string { return GenerateRandomPrefixedSHA256()[7:] } // GenerateRandomPrefixedSHA256 generates a random 64 character SHA 256 hash string, prefixed with `sha256:` func GenerateRandomPrefixedSHA256() string { hash := make([]byte, 32) _, _ = rand.Read(hash) sb := bytes.NewBufferString("sha256:") sb.Grow(64) for _, h := range hash { _, _ = fmt.Fprintf(sb, "%02x", h) } return sb.String() } ================================================ FILE: internal/util/util.go ================================================ package util // SliceEqual compares two slices and checks whether they have equal content func SliceEqual(s1, s2 []string) bool { if len(s1) != len(s2) { return false } for i := range s1 { if s1[i] != s2[i] { return false } } return true } // SliceSubtract subtracts the content of slice a2 from slice a1 func SliceSubtract(a1, a2 []string) []string { a := []string{} for _, e1 := range a1 { found := false for _, e2 := range a2 { if e1 == e2 { found = true break } } if !found { a = append(a, e1) } } return a } // StringMapSubtract subtracts the content of structmap m2 from structmap m1 func StringMapSubtract(m1, m2 map[string]string) map[string]string { m := map[string]string{} for k1, v1 := range m1 { if v2, ok := m2[k1]; ok { if v2 != v1 { m[k1] = v1 } } else { m[k1] = v1 } } return m } // StructMapSubtract subtracts the content of structmap m2 from structmap m1 func StructMapSubtract(m1, m2 map[string]struct{}) map[string]struct{} { m := map[string]struct{}{} for k1, v1 := range m1 { if _, ok := m2[k1]; !ok { m[k1] = v1 } } return m } ================================================ FILE: internal/util/util_test.go ================================================ package util import ( "regexp" "testing" "github.com/stretchr/testify/assert" ) func TestSliceEqual_True(t *testing.T) { s1 := []string{"a", "b", "c"} s2 := []string{"a", "b", "c"} result := SliceEqual(s1, s2) assert.True(t, result) } func TestSliceEqual_DifferentLengths(t *testing.T) { s1 := []string{"a", "b", "c"} s2 := []string{"a", "b", "c", "d"} result := SliceEqual(s1, s2) assert.False(t, result) } func TestSliceEqual_DifferentContents(t *testing.T) { s1 := []string{"a", "b", "c"} s2 := []string{"a", "b", "d"} result := SliceEqual(s1, s2) assert.False(t, result) } func TestSliceSubtract(t *testing.T) { a1 := []string{"a", "b", "c"} a2 := []string{"a", "c"} result := SliceSubtract(a1, a2) assert.Equal(t, []string{"b"}, result) assert.Equal(t, []string{"a", "b", "c"}, a1) assert.Equal(t, []string{"a", "c"}, a2) } func TestStringMapSubtract(t *testing.T) { m1 := map[string]string{"a": "a", "b": "b", "c": "sea"} m2 := map[string]string{"a": "a", "c": "c"} result := StringMapSubtract(m1, m2) assert.Equal(t, map[string]string{"b": "b", "c": "sea"}, result) assert.Equal(t, map[string]string{"a": "a", "b": "b", "c": "sea"}, m1) assert.Equal(t, map[string]string{"a": "a", "c": "c"}, m2) } func TestStructMapSubtract(t *testing.T) { x := struct{}{} m1 := map[string]struct{}{"a": x, "b": x, "c": x} m2 := map[string]struct{}{"a": x, "c": x} result := StructMapSubtract(m1, m2) assert.Equal(t, map[string]struct{}{"b": x}, result) assert.Equal(t, map[string]struct{}{"a": x, "b": x, "c": x}, m1) assert.Equal(t, map[string]struct{}{"a": x, "c": x}, m2) } // GenerateRandomSHA256 generates a random 64 character SHA 256 hash string func TestGenerateRandomSHA256(t *testing.T) { res := GenerateRandomSHA256() assert.Len(t, res, 64) assert.NotContains(t, res, "sha256:") } func TestGenerateRandomPrefixedSHA256(t *testing.T) { res := GenerateRandomPrefixedSHA256() assert.Regexp(t, regexp.MustCompile("sha256:[0-9|a-f]{64}"), res) } ================================================ FILE: main.go ================================================ package main import ( "github.com/containrrr/watchtower/cmd" log "github.com/sirupsen/logrus" ) func init() { log.SetLevel(log.InfoLevel) } func main() { cmd.Execute() } ================================================ FILE: mkdocs.yml ================================================ site_name: Watchtower site_url: https://containrrr.dev/watchtower/ repo_url: https://github.com/containrrr/watchtower/ edit_uri: edit/main/docs/ theme: name: 'material' palette: - media: "(prefers-color-scheme: light)" scheme: containrrr toggle: icon: material/weather-night name: Switch to dark mode - media: "(prefers-color-scheme: dark)" scheme: containrrr-dark toggle: icon: material/weather-sunny name: Switch to light mode logo: images/logo-450px.png favicon: images/favicon.ico extra_css: - stylesheets/theme.css markdown_extensions: - toc: permalink: True separator: "_" - admonition - pymdownx.highlight - pymdownx.superfences - pymdownx.magiclink: repo_url_shortener: True provider: github user: containrrr repo: watchtower - pymdownx.saneheaders - pymdownx.tabbed: alternate_style: true nav: - 'Home': 'index.md' - 'Introduction': 'introduction.md' - 'Usage overview': 'usage-overview.md' - 'Arguments': 'arguments.md' - 'Notifications': 'notifications.md' - 'Container selection': 'container-selection.md' - 'Private registries': 'private-registries.md' - 'Linked containers': 'linked-containers.md' - 'Remote hosts': 'remote-hosts.md' - 'Secure connections': 'secure-connections.md' - 'Stop signals': 'stop-signals.md' - 'Lifecycle hooks': 'lifecycle-hooks.md' - 'Running multiple instances': 'running-multiple-instances.md' - 'HTTP API Mode': 'http-api-mode.md' - 'Metrics': 'metrics.md' plugins: - search ================================================ FILE: oryxBuildBinary ================================================ [File too large to display: 19.2 MB] ================================================ FILE: pkg/api/api.go ================================================ package api import ( "fmt" "net/http" log "github.com/sirupsen/logrus" ) const tokenMissingMsg = "api token is empty or has not been set. exiting" // API is the http server responsible for serving the HTTP API endpoints type API struct { Token string hasHandlers bool } // New is a factory function creating a new API instance func New(token string) *API { return &API{ Token: token, hasHandlers: false, } } // RequireToken is wrapper around http.HandleFunc that checks token validity func (api *API) RequireToken(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") want := fmt.Sprintf("Bearer %s", api.Token) if auth != want { w.WriteHeader(http.StatusUnauthorized) return } log.Debug("Valid token found.") fn(w, r) } } // RegisterFunc is a wrapper around http.HandleFunc that also sets the flag used to determine whether to launch the API func (api *API) RegisterFunc(path string, fn http.HandlerFunc) { api.hasHandlers = true http.HandleFunc(path, api.RequireToken(fn)) } // RegisterHandler is a wrapper around http.Handler that also sets the flag used to determine whether to launch the API func (api *API) RegisterHandler(path string, handler http.Handler) { api.hasHandlers = true http.Handle(path, api.RequireToken(handler.ServeHTTP)) } // Start the API and serve over HTTP. Requires an API Token to be set. func (api *API) Start(block bool) error { if !api.hasHandlers { log.Debug("Watchtower HTTP API skipped.") return nil } if api.Token == "" { log.Fatal(tokenMissingMsg) } if block { runHTTPServer() } else { go func() { runHTTPServer() }() } return nil } func runHTTPServer() { log.Fatal(http.ListenAndServe(":8080", nil)) } ================================================ FILE: pkg/api/api_test.go ================================================ package api import ( "io" "net/http" "net/http/httptest" "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) const ( token = "123123123" ) func TestAPI(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "API Suite") } var _ = Describe("API", func() { api := New(token) Describe("RequireToken middleware", func() { It("should return 401 Unauthorized when token is not provided", func() { handlerFunc := api.RequireToken(testHandler) rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/hello", nil) handlerFunc(rec, req) Expect(rec.Code).To(Equal(http.StatusUnauthorized)) }) It("should return 401 Unauthorized when token is invalid", func() { handlerFunc := api.RequireToken(testHandler) rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/hello", nil) req.Header.Set("Authorization", "Bearer 123") handlerFunc(rec, req) Expect(rec.Code).To(Equal(http.StatusUnauthorized)) }) It("should return 200 OK when token is valid", func() { handlerFunc := api.RequireToken(testHandler) rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/hello", nil) req.Header.Set("Authorization", "Bearer " + token) handlerFunc(rec, req) Expect(rec.Code).To(Equal(http.StatusOK)) }) }) }) func testHandler(w http.ResponseWriter, req *http.Request) { _, _ = io.WriteString(w, "Hello!") } ================================================ FILE: pkg/api/metrics/metrics.go ================================================ package metrics import ( "github.com/containrrr/watchtower/pkg/metrics" "net/http" "github.com/prometheus/client_golang/prometheus/promhttp" ) // Handler is an HTTP handle for serving metric data type Handler struct { Path string Handle http.HandlerFunc Metrics *metrics.Metrics } // New is a factory function creating a new Metrics instance func New() *Handler { m := metrics.Default() handler := promhttp.Handler() return &Handler{ Path: "/v1/metrics", Handle: handler.ServeHTTP, Metrics: m, } } ================================================ FILE: pkg/api/metrics/metrics_test.go ================================================ package metrics_test import ( "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/containrrr/watchtower/pkg/api" metricsAPI "github.com/containrrr/watchtower/pkg/api/metrics" "github.com/containrrr/watchtower/pkg/metrics" ) const ( token = "123123123" getURL = "http://localhost:8080/v1/metrics" ) func TestMetrics(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Metrics Suite") } func getWithToken(handler http.Handler) map[string]string { metricMap := map[string]string{} respWriter := httptest.NewRecorder() req := httptest.NewRequest("GET", getURL, nil) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) handler.ServeHTTP(respWriter, req) res := respWriter.Result() body, _ := io.ReadAll(res.Body) for _, line := range strings.Split(string(body), "\n") { if len(line) < 1 || line[0] == '#' { continue } parts := strings.Split(line, " ") metricMap[parts[0]] = parts[1] } return metricMap } var _ = Describe("the metrics API", func() { httpAPI := api.New(token) m := metricsAPI.New() handleReq := httpAPI.RequireToken(m.Handle) tryGetMetrics := func() map[string]string { return getWithToken(handleReq) } It("should serve metrics", func() { Expect(tryGetMetrics()).To(HaveKeyWithValue("watchtower_containers_updated", "0")) metric := &metrics.Metric{ Scanned: 4, Updated: 3, Failed: 1, } metrics.RegisterScan(metric) Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue()) Eventually(tryGetMetrics).Should(SatisfyAll( HaveKeyWithValue("watchtower_containers_updated", "3"), HaveKeyWithValue("watchtower_containers_failed", "1"), HaveKeyWithValue("watchtower_containers_scanned", "4"), HaveKeyWithValue("watchtower_scans_total", "1"), HaveKeyWithValue("watchtower_scans_skipped", "0"), )) for i := 0; i < 3; i++ { metrics.RegisterScan(nil) } Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue()) Eventually(tryGetMetrics).Should(SatisfyAll( HaveKeyWithValue("watchtower_scans_total", "4"), HaveKeyWithValue("watchtower_scans_skipped", "3"), )) }) }) ================================================ FILE: pkg/api/update/update.go ================================================ package update import ( "io" "net/http" "os" "strings" log "github.com/sirupsen/logrus" ) var ( lock chan bool ) // New is a factory function creating a new Handler instance func New(updateFn func(images []string), updateLock chan bool) *Handler { if updateLock != nil { lock = updateLock } else { lock = make(chan bool, 1) lock <- true } return &Handler{ fn: updateFn, Path: "/v1/update", } } // Handler is an API handler used for triggering container update scans type Handler struct { fn func(images []string) Path string } // Handle is the actual http.Handle function doing all the heavy lifting func (handle *Handler) Handle(w http.ResponseWriter, r *http.Request) { log.Info("Updates triggered by HTTP API request.") _, err := io.Copy(os.Stdout, r.Body) if err != nil { log.Println(err) return } var images []string imageQueries, found := r.URL.Query()["image"] if found { for _, image := range imageQueries { images = append(images, strings.Split(image, ",")...) } } else { images = nil } if len(images) > 0 { chanValue := <-lock defer func() { lock <- chanValue }() handle.fn(images) } else { select { case chanValue := <-lock: defer func() { lock <- chanValue }() handle.fn(images) default: log.Debug("Skipped. Another update already running.") } } } ================================================ FILE: pkg/container/cgroup_id.go ================================================ package container import ( "fmt" "os" "regexp" "github.com/containrrr/watchtower/pkg/types" ) var dockerContainerPattern = regexp.MustCompile(`[0-9]+:.*:/docker/([a-f|0-9]{64})`) // GetRunningContainerID tries to resolve the current container ID from the current process cgroup information func GetRunningContainerID() (cid types.ContainerID, err error) { file, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", os.Getpid())) if err != nil { return } return getRunningContainerIDFromString(string(file)), nil } func getRunningContainerIDFromString(s string) types.ContainerID { matches := dockerContainerPattern.FindStringSubmatch(s) if len(matches) < 2 { return "" } return types.ContainerID(matches[1]) } ================================================ FILE: pkg/container/cgroup_id_test.go ================================================ package container import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) var _ = Describe("GetRunningContainerID", func() { When("a matching container ID is found", func() { It("should return that container ID", func() { cid := getRunningContainerIDFromString(` 15:name=systemd:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 14:misc:/ 13:rdma:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 12:pids:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 11:hugetlb:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 10:net_prio:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 9:perf_event:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 8:net_cls:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 7:freezer:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 6:devices:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 5:blkio:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 4:cpuacct:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 3:cpu:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 2:cpuset:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 1:memory:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 0::/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 `) Expect(cid).To(BeEquivalentTo(`991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377`)) }) }) When("no matching container ID could be found", func() { It("should return that container ID", func() { cid := getRunningContainerIDFromString(`14:misc:/`) Expect(cid).To(BeEmpty()) }) }) }) // ================================================ FILE: pkg/container/client.go ================================================ package container import ( "bytes" "fmt" "io" "strings" "time" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/network" sdkClient "github.com/docker/docker/client" log "github.com/sirupsen/logrus" "golang.org/x/net/context" "github.com/containrrr/watchtower/pkg/registry" "github.com/containrrr/watchtower/pkg/registry/digest" t "github.com/containrrr/watchtower/pkg/types" ) const defaultStopSignal = "SIGTERM" // A Client is the interface through which watchtower interacts with the // Docker API. type Client interface { ListContainers(t.Filter) ([]t.Container, error) GetContainer(containerID t.ContainerID) (t.Container, error) StopContainer(t.Container, time.Duration) error StartContainer(t.Container) (t.ContainerID, error) RenameContainer(t.Container, string) error IsContainerStale(t.Container, t.UpdateParams) (stale bool, latestImage t.ImageID, err error) ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error) RemoveImageByID(t.ImageID) error WarnOnHeadPullFailed(container t.Container) bool } // NewClient returns a new Client instance which can be used to interact with // the Docker API. // The client reads its configuration from the following environment variables: // - DOCKER_HOST the docker-engine host to send api requests to // - DOCKER_TLS_VERIFY whether to verify tls certificates // - DOCKER_API_VERSION the minimum docker api version to work with func NewClient(opts ClientOptions) Client { cli, err := sdkClient.NewClientWithOpts(sdkClient.FromEnv) if err != nil { log.Fatalf("Error instantiating Docker client: %s", err) } return dockerClient{ api: cli, ClientOptions: opts, } } // ClientOptions contains the options for how the docker client wrapper should behave type ClientOptions struct { RemoveVolumes bool IncludeStopped bool ReviveStopped bool IncludeRestarting bool WarnOnHeadFailed WarningStrategy } // WarningStrategy is a value determining when to show warnings type WarningStrategy string const ( // WarnAlways warns whenever the problem occurs WarnAlways WarningStrategy = "always" // WarnNever never warns when the problem occurs WarnNever WarningStrategy = "never" // WarnAuto skips warning when the problem was expected WarnAuto WarningStrategy = "auto" ) type dockerClient struct { api sdkClient.CommonAPIClient ClientOptions } func (client dockerClient) WarnOnHeadPullFailed(container t.Container) bool { if client.WarnOnHeadFailed == WarnAlways { return true } if client.WarnOnHeadFailed == WarnNever { return false } return registry.WarnOnAPIConsumption(container) } func (client dockerClient) ListContainers(fn t.Filter) ([]t.Container, error) { cs := []t.Container{} bg := context.Background() if client.IncludeStopped && client.IncludeRestarting { log.Debug("Retrieving running, stopped, restarting and exited containers") } else if client.IncludeStopped { log.Debug("Retrieving running, stopped and exited containers") } else if client.IncludeRestarting { log.Debug("Retrieving running and restarting containers") } else { log.Debug("Retrieving running containers") } filter := client.createListFilter() containers, err := client.api.ContainerList( bg, types.ContainerListOptions{ Filters: filter, }) if err != nil { return nil, err } for _, runningContainer := range containers { c, err := client.GetContainer(t.ContainerID(runningContainer.ID)) if err != nil { return nil, err } if fn(c) { cs = append(cs, c) } } return cs, nil } func (client dockerClient) createListFilter() filters.Args { filterArgs := filters.NewArgs() filterArgs.Add("status", "running") if client.IncludeStopped { filterArgs.Add("status", "created") filterArgs.Add("status", "exited") } if client.IncludeRestarting { filterArgs.Add("status", "restarting") } return filterArgs } func (client dockerClient) GetContainer(containerID t.ContainerID) (t.Container, error) { bg := context.Background() containerInfo, err := client.api.ContainerInspect(bg, string(containerID)) if err != nil { return &Container{}, err } netType, netContainerId, found := strings.Cut(string(containerInfo.HostConfig.NetworkMode), ":") if found && netType == "container" { parentContainer, err := client.api.ContainerInspect(bg, netContainerId) if err != nil { log.WithFields(map[string]interface{}{ "container": containerInfo.Name, "error": err, "network-container": netContainerId, }).Warnf("Unable to resolve network container: %v", err) } else { // Replace the container ID with a container name to allow it to reference the re-created network container containerInfo.HostConfig.NetworkMode = container.NetworkMode(fmt.Sprintf("container:%s", parentContainer.Name)) } } imageInfo, _, err := client.api.ImageInspectWithRaw(bg, containerInfo.Image) if err != nil { log.Warnf("Failed to retrieve container image info: %v", err) return &Container{containerInfo: &containerInfo, imageInfo: nil}, nil } return &Container{containerInfo: &containerInfo, imageInfo: &imageInfo}, nil } func (client dockerClient) StopContainer(c t.Container, timeout time.Duration) error { bg := context.Background() signal := c.StopSignal() if signal == "" { signal = defaultStopSignal } idStr := string(c.ID()) shortID := c.ID().ShortID() if c.IsRunning() { log.Infof("Stopping %s (%s) with %s", c.Name(), shortID, signal) if err := client.api.ContainerKill(bg, idStr, signal); err != nil { return err } } // TODO: This should probably be checked. _ = client.waitForStopOrTimeout(c, timeout) if c.ContainerInfo().HostConfig.AutoRemove { log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", shortID) } else { log.Debugf("Removing container %s", shortID) if err := client.api.ContainerRemove(bg, idStr, types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.RemoveVolumes}); err != nil { if sdkClient.IsErrNotFound(err) { log.Debugf("Container %s not found, skipping removal.", shortID) return nil } return err } } // Wait for container to be removed. In this case an error is a good thing if err := client.waitForStopOrTimeout(c, timeout); err == nil { return fmt.Errorf("container %s (%s) could not be removed", c.Name(), shortID) } return nil } func (client dockerClient) GetNetworkConfig(c t.Container) *network.NetworkingConfig { config := &network.NetworkingConfig{ EndpointsConfig: c.ContainerInfo().NetworkSettings.Networks, } for _, ep := range config.EndpointsConfig { aliases := make([]string, 0, len(ep.Aliases)) cidAlias := c.ID().ShortID() // Remove the old container ID alias from the network aliases, as it would accumulate across updates otherwise for _, alias := range ep.Aliases { if alias == cidAlias { continue } aliases = append(aliases, alias) } ep.Aliases = aliases } return config } func (client dockerClient) StartContainer(c t.Container) (t.ContainerID, error) { bg := context.Background() config := c.GetCreateConfig() hostConfig := c.GetCreateHostConfig() networkConfig := client.GetNetworkConfig(c) // simpleNetworkConfig is a networkConfig with only 1 network. // see: https://github.com/docker/docker/issues/29265 simpleNetworkConfig := func() *network.NetworkingConfig { oneEndpoint := make(map[string]*network.EndpointSettings) for k, v := range networkConfig.EndpointsConfig { oneEndpoint[k] = v // we only need 1 break } return &network.NetworkingConfig{EndpointsConfig: oneEndpoint} }() name := c.Name() log.Infof("Creating %s", name) createdContainer, err := client.api.ContainerCreate(bg, config, hostConfig, simpleNetworkConfig, nil, name) if err != nil { return "", err } if !(hostConfig.NetworkMode.IsHost()) { for k := range simpleNetworkConfig.EndpointsConfig { err = client.api.NetworkDisconnect(bg, k, createdContainer.ID, true) if err != nil { return "", err } } for k, v := range networkConfig.EndpointsConfig { err = client.api.NetworkConnect(bg, k, createdContainer.ID, v) if err != nil { return "", err } } } createdContainerID := t.ContainerID(createdContainer.ID) if !c.IsRunning() && !client.ReviveStopped { return createdContainerID, nil } return createdContainerID, client.doStartContainer(bg, c, createdContainer) } func (client dockerClient) doStartContainer(bg context.Context, c t.Container, creation container.CreateResponse) error { name := c.Name() log.Debugf("Starting container %s (%s)", name, t.ContainerID(creation.ID).ShortID()) err := client.api.ContainerStart(bg, creation.ID, types.ContainerStartOptions{}) if err != nil { return err } return nil } func (client dockerClient) RenameContainer(c t.Container, newName string) error { bg := context.Background() log.Debugf("Renaming container %s (%s) to %s", c.Name(), c.ID().ShortID(), newName) return client.api.ContainerRename(bg, string(c.ID()), newName) } func (client dockerClient) IsContainerStale(container t.Container, params t.UpdateParams) (stale bool, latestImage t.ImageID, err error) { ctx := context.Background() if container.IsNoPull(params) { log.Debugf("Skipping image pull.") } else if err := client.PullImage(ctx, container); err != nil { return false, container.SafeImageID(), err } return client.HasNewImage(ctx, container) } func (client dockerClient) HasNewImage(ctx context.Context, container t.Container) (hasNew bool, latestImage t.ImageID, err error) { currentImageID := t.ImageID(container.ContainerInfo().ContainerJSONBase.Image) imageName := container.ImageName() newImageInfo, _, err := client.api.ImageInspectWithRaw(ctx, imageName) if err != nil { return false, currentImageID, err } newImageID := t.ImageID(newImageInfo.ID) if newImageID == currentImageID { log.Debugf("No new images found for %s", container.Name()) return false, currentImageID, nil } log.Infof("Found new %s image (%s)", imageName, newImageID.ShortID()) return true, newImageID, nil } // PullImage pulls the latest image for the supplied container, optionally skipping if it's digest can be confirmed // to match the one that the registry reports via a HEAD request func (client dockerClient) PullImage(ctx context.Context, container t.Container) error { containerName := container.Name() imageName := container.ImageName() fields := log.Fields{ "image": imageName, "container": containerName, } if strings.HasPrefix(imageName, "sha256:") { return fmt.Errorf("container uses a pinned image, and cannot be updated by watchtower") } log.WithFields(fields).Debugf("Trying to load authentication credentials.") opts, err := registry.GetPullOptions(imageName) if err != nil { log.Debugf("Error loading authentication credentials %s", err) return err } if opts.RegistryAuth != "" { log.Debug("Credentials loaded") } log.WithFields(fields).Debugf("Checking if pull is needed") if match, err := digest.CompareDigest(container, opts.RegistryAuth); err != nil { headLevel := log.DebugLevel if client.WarnOnHeadPullFailed(container) { headLevel = log.WarnLevel } log.WithFields(fields).Logf(headLevel, "Could not do a head request for %q, falling back to regular pull.", imageName) log.WithFields(fields).Log(headLevel, "Reason: ", err) } else if match { log.Debug("No pull needed. Skipping image.") return nil } else { log.Debug("Digests did not match, doing a pull.") } log.WithFields(fields).Debugf("Pulling image") response, err := client.api.ImagePull(ctx, imageName, opts) if err != nil { log.Debugf("Error pulling image %s, %s", imageName, err) return err } defer response.Close() // the pull request will be aborted prematurely unless the response is read if _, err = io.ReadAll(response); err != nil { log.Error(err) return err } return nil } func (client dockerClient) RemoveImageByID(id t.ImageID) error { log.Infof("Removing image %s", id.ShortID()) items, err := client.api.ImageRemove( context.Background(), string(id), types.ImageRemoveOptions{ Force: true, }) if log.IsLevelEnabled(log.DebugLevel) { deleted := strings.Builder{} untagged := strings.Builder{} for _, item := range items { if item.Deleted != "" { if deleted.Len() > 0 { deleted.WriteString(`, `) } deleted.WriteString(t.ImageID(item.Deleted).ShortID()) } if item.Untagged != "" { if untagged.Len() > 0 { untagged.WriteString(`, `) } untagged.WriteString(t.ImageID(item.Untagged).ShortID()) } } fields := log.Fields{`deleted`: deleted.String(), `untagged`: untagged.String()} log.WithFields(fields).Debug("Image removal completed") } return err } func (client dockerClient) ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error) { bg := context.Background() clog := log.WithField("containerID", containerID) // Create the exec execConfig := types.ExecConfig{ Tty: true, Detach: false, Cmd: []string{"sh", "-c", command}, } exec, err := client.api.ContainerExecCreate(bg, string(containerID), execConfig) if err != nil { return false, err } response, attachErr := client.api.ContainerExecAttach(bg, exec.ID, types.ExecStartCheck{ Tty: true, Detach: false, }) if attachErr != nil { clog.Errorf("Failed to extract command exec logs: %v", attachErr) } // Run the exec execStartCheck := types.ExecStartCheck{Detach: false, Tty: true} err = client.api.ContainerExecStart(bg, exec.ID, execStartCheck) if err != nil { return false, err } var output string if attachErr == nil { defer response.Close() var writer bytes.Buffer written, err := writer.ReadFrom(response.Reader) if err != nil { clog.Error(err) } else if written > 0 { output = strings.TrimSpace(writer.String()) } } // Inspect the exec to get the exit code and print a message if the // exit code is not success. skipUpdate, err := client.waitForExecOrTimeout(bg, exec.ID, output, timeout) if err != nil { return true, err } return skipUpdate, nil } func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, execOutput string, timeout int) (SkipUpdate bool, err error) { const ExTempFail = 75 var ctx context.Context var cancel context.CancelFunc if timeout > 0 { ctx, cancel = context.WithTimeout(bg, time.Duration(timeout)*time.Minute) defer cancel() } else { ctx = bg } for { execInspect, err := client.api.ContainerExecInspect(ctx, ID) //goland:noinspection GoNilness log.WithFields(log.Fields{ "exit-code": execInspect.ExitCode, "exec-id": execInspect.ExecID, "running": execInspect.Running, "container-id": execInspect.ContainerID, }).Debug("Awaiting timeout or completion") if err != nil { return false, err } if execInspect.Running { time.Sleep(1 * time.Second) continue } if len(execOutput) > 0 { log.Infof("Command output:\n%v", execOutput) } if execInspect.ExitCode == ExTempFail { return true, nil } if execInspect.ExitCode > 0 { return false, fmt.Errorf("command exited with code %v %s", execInspect.ExitCode, execOutput) } break } return false, nil } func (client dockerClient) waitForStopOrTimeout(c t.Container, waitTime time.Duration) error { bg := context.Background() timeout := time.After(waitTime) for { select { case <-timeout: return nil default: if ci, err := client.api.ContainerInspect(bg, string(c.ID())); err != nil { return err } else if !ci.State.Running { return nil } } time.Sleep(1 * time.Second) } } ================================================ FILE: pkg/container/client_test.go ================================================ package container import ( "github.com/docker/docker/api/types/network" "time" "github.com/containrrr/watchtower/internal/util" "github.com/containrrr/watchtower/pkg/container/mocks" "github.com/containrrr/watchtower/pkg/filters" t "github.com/containrrr/watchtower/pkg/types" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/backend" cli "github.com/docker/docker/client" "github.com/docker/docker/errdefs" "github.com/onsi/gomega/gbytes" "github.com/onsi/gomega/ghttp" "github.com/sirupsen/logrus" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" gt "github.com/onsi/gomega/types" "context" "net/http" ) var _ = Describe("the client", func() { var docker *cli.Client var mockServer *ghttp.Server BeforeEach(func() { mockServer = ghttp.NewServer() docker, _ = cli.NewClientWithOpts( cli.WithHost(mockServer.URL()), cli.WithHTTPClient(mockServer.HTTPTestServer.Client())) }) AfterEach(func() { mockServer.Close() }) Describe("WarnOnHeadPullFailed", func() { containerUnknown := MockContainer(WithImageName("unknown.repo/prefix/imagename:latest")) containerKnown := MockContainer(WithImageName("docker.io/prefix/imagename:latest")) When(`warn on head failure is set to "always"`, func() { c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAlways}} It("should always return true", func() { Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeTrue()) Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue()) }) }) When(`warn on head failure is set to "auto"`, func() { c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAuto}} It("should return false for unknown repos", func() { Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse()) }) It("should return true for known repos", func() { Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue()) }) }) When(`warn on head failure is set to "never"`, func() { c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnNever}} It("should never return true", func() { Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse()) Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeFalse()) }) }) }) When("pulling the latest image", func() { When("the image consist of a pinned hash", func() { It("should gracefully fail with a useful message", func() { c := dockerClient{} pinnedContainer := MockContainer(WithImageName("sha256:fa5269854a5e615e51a72b17ad3fd1e01268f278a6684c8ed3c5f0cdce3f230b")) err := c.PullImage(context.Background(), pinnedContainer) Expect(err).To(MatchError(`container uses a pinned image, and cannot be updated by watchtower`)) }) }) }) When("removing a running container", func() { When("the container still exist after stopping", func() { It("should attempt to remove the container", func() { container := MockContainer(WithContainerState(types.ContainerState{Running: true})) containerStopped := MockContainer(WithContainerState(types.ContainerState{Running: false})) cid := container.ContainerInfo().ID mockServer.AppendHandlers( mocks.KillContainerHandler(cid, mocks.Found), mocks.GetContainerHandler(cid, containerStopped.ContainerInfo()), mocks.RemoveContainerHandler(cid, mocks.Found), mocks.GetContainerHandler(cid, nil), ) Expect(dockerClient{api: docker}.StopContainer(container, time.Minute)).To(Succeed()) }) }) When("the container does not exist after stopping", func() { It("should not cause an error", func() { container := MockContainer(WithContainerState(types.ContainerState{Running: true})) cid := container.ContainerInfo().ID mockServer.AppendHandlers( mocks.KillContainerHandler(cid, mocks.Found), mocks.GetContainerHandler(cid, nil), mocks.RemoveContainerHandler(cid, mocks.Missing), ) Expect(dockerClient{api: docker}.StopContainer(container, time.Minute)).To(Succeed()) }) }) }) When("removing a image", func() { When("debug logging is enabled", func() { It("should log removed and untagged images", func() { imageA := util.GenerateRandomSHA256() imageAParent := util.GenerateRandomSHA256() images := map[string][]string{imageA: {imageAParent}} mockServer.AppendHandlers(mocks.RemoveImageHandler(images)) c := dockerClient{api: docker} resetLogrus, logbuf := captureLogrus(logrus.DebugLevel) defer resetLogrus() Expect(c.RemoveImageByID(t.ImageID(imageA))).To(Succeed()) shortA := t.ImageID(imageA).ShortID() shortAParent := t.ImageID(imageAParent).ShortID() Eventually(logbuf).Should(gbytes.Say(`deleted="%v, %v" untagged="?%v"?`, shortA, shortAParent, shortA)) }) }) When("image is not found", func() { It("should return an error", func() { image := util.GenerateRandomSHA256() mockServer.AppendHandlers(mocks.RemoveImageHandler(nil)) c := dockerClient{api: docker} err := c.RemoveImageByID(t.ImageID(image)) Expect(errdefs.IsNotFound(err)).To(BeTrue()) }) }) }) When("listing containers", func() { When("no filter is provided", func() { It("should return all available containers", func() { mockServer.AppendHandlers(mocks.ListContainersHandler("running")) mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...) client := dockerClient{ api: docker, ClientOptions: ClientOptions{}, } containers, err := client.ListContainers(filters.NoFilter) Expect(err).NotTo(HaveOccurred()) Expect(containers).To(HaveLen(2)) }) }) When("a filter matching nothing", func() { It("should return an empty array", func() { mockServer.AppendHandlers(mocks.ListContainersHandler("running")) mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...) filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter) client := dockerClient{ api: docker, ClientOptions: ClientOptions{}, } containers, err := client.ListContainers(filter) Expect(err).NotTo(HaveOccurred()) Expect(containers).To(BeEmpty()) }) }) When("a watchtower filter is provided", func() { It("should return only the watchtower container", func() { mockServer.AppendHandlers(mocks.ListContainersHandler("running")) mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...) client := dockerClient{ api: docker, ClientOptions: ClientOptions{}, } containers, err := client.ListContainers(filters.WatchtowerContainersFilter) Expect(err).NotTo(HaveOccurred()) Expect(containers).To(ConsistOf(withContainerImageName(Equal("containrrr/watchtower:latest")))) }) }) When(`include stopped is enabled`, func() { It("should return both stopped and running containers", func() { mockServer.AppendHandlers(mocks.ListContainersHandler("running", "exited", "created")) mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Stopped, &mocks.Watchtower, &mocks.Running)...) client := dockerClient{ api: docker, ClientOptions: ClientOptions{IncludeStopped: true}, } containers, err := client.ListContainers(filters.NoFilter) Expect(err).NotTo(HaveOccurred()) Expect(containers).To(ContainElement(havingRunningState(false))) }) }) When(`include restarting is enabled`, func() { It("should return both restarting and running containers", func() { mockServer.AppendHandlers(mocks.ListContainersHandler("running", "restarting")) mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running, &mocks.Restarting)...) client := dockerClient{ api: docker, ClientOptions: ClientOptions{IncludeRestarting: true}, } containers, err := client.ListContainers(filters.NoFilter) Expect(err).NotTo(HaveOccurred()) Expect(containers).To(ContainElement(havingRestartingState(true))) }) }) When(`include restarting is disabled`, func() { It("should not return restarting containers", func() { mockServer.AppendHandlers(mocks.ListContainersHandler("running")) mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...) client := dockerClient{ api: docker, ClientOptions: ClientOptions{IncludeRestarting: false}, } containers, err := client.ListContainers(filters.NoFilter) Expect(err).NotTo(HaveOccurred()) Expect(containers).NotTo(ContainElement(havingRestartingState(true))) }) }) When(`a container uses container network mode`, func() { When(`the network container can be resolved`, func() { It("should return the container name instead of the ID", func() { consumerContainerRef := mocks.NetConsumerOK mockServer.AppendHandlers(mocks.GetContainerHandlers(&consumerContainerRef)...) client := dockerClient{ api: docker, ClientOptions: ClientOptions{}, } container, err := client.GetContainer(consumerContainerRef.ContainerID()) Expect(err).NotTo(HaveOccurred()) networkMode := container.ContainerInfo().HostConfig.NetworkMode Expect(networkMode.ConnectedContainer()).To(Equal(mocks.NetSupplierContainerName)) }) }) When(`the network container cannot be resolved`, func() { It("should still return the container ID", func() { consumerContainerRef := mocks.NetConsumerInvalidSupplier mockServer.AppendHandlers(mocks.GetContainerHandlers(&consumerContainerRef)...) client := dockerClient{ api: docker, ClientOptions: ClientOptions{}, } container, err := client.GetContainer(consumerContainerRef.ContainerID()) Expect(err).NotTo(HaveOccurred()) networkMode := container.ContainerInfo().HostConfig.NetworkMode Expect(networkMode.ConnectedContainer()).To(Equal(mocks.NetSupplierNotFoundID)) }) }) }) }) Describe(`ExecuteCommand`, func() { When(`logging`, func() { It("should include container id field", func() { client := dockerClient{ api: docker, ClientOptions: ClientOptions{}, } // Capture logrus output in buffer resetLogrus, logbuf := captureLogrus(logrus.DebugLevel) defer resetLogrus() user := "" containerID := t.ContainerID("ex-cont-id") execID := "ex-exec-id" cmd := "exec-cmd" mockServer.AppendHandlers( // API.ContainerExecCreate ghttp.CombineHandlers( ghttp.VerifyRequest("POST", HaveSuffix("containers/%v/exec", containerID)), ghttp.VerifyJSONRepresenting(types.ExecConfig{ User: user, Detach: false, Tty: true, Cmd: []string{ "sh", "-c", cmd, }, }), ghttp.RespondWithJSONEncoded(http.StatusOK, types.IDResponse{ID: execID}), ), // API.ContainerExecStart ghttp.CombineHandlers( ghttp.VerifyRequest("POST", HaveSuffix("exec/%v/start", execID)), ghttp.VerifyJSONRepresenting(types.ExecStartCheck{ Detach: false, Tty: true, }), ghttp.RespondWith(http.StatusOK, nil), ), // API.ContainerExecInspect ghttp.CombineHandlers( ghttp.VerifyRequest("GET", HaveSuffix("exec/ex-exec-id/json")), ghttp.RespondWithJSONEncoded(http.StatusOK, backend.ExecInspect{ ID: execID, Running: false, ExitCode: nil, ProcessConfig: &backend.ExecProcessConfig{ Entrypoint: "sh", Arguments: []string{"-c", cmd}, User: user, }, ContainerID: string(containerID), }), ), ) _, err := client.ExecuteCommand(containerID, cmd, 1) Expect(err).NotTo(HaveOccurred()) // Note: Since Execute requires opening up a raw TCP stream to the daemon for the output, this will fail // when using the mock API server. Regardless of the outcome, the log should include the container ID Eventually(logbuf).Should(gbytes.Say(`containerID="?ex-cont-id"?`)) }) }) }) Describe(`GetNetworkConfig`, func() { When(`providing a container with network aliases`, func() { It(`should omit the container ID alias`, func() { client := dockerClient{ api: docker, ClientOptions: ClientOptions{IncludeRestarting: false}, } container := MockContainer(WithImageName("docker.io/prefix/imagename:latest")) aliases := []string{"One", "Two", container.ID().ShortID(), "Four"} endpoints := map[string]*network.EndpointSettings{ `test`: {Aliases: aliases}, } container.containerInfo.NetworkSettings = &types.NetworkSettings{Networks: endpoints} Expect(container.ContainerInfo().NetworkSettings.Networks[`test`].Aliases).To(Equal(aliases)) Expect(client.GetNetworkConfig(container).EndpointsConfig[`test`].Aliases).To(Equal([]string{"One", "Two", "Four"})) }) }) }) }) // Capture logrus output in buffer func captureLogrus(level logrus.Level) (func(), *gbytes.Buffer) { logbuf := gbytes.NewBuffer() origOut := logrus.StandardLogger().Out logrus.SetOutput(logbuf) origLev := logrus.StandardLogger().Level logrus.SetLevel(level) return func() { logrus.SetOutput(origOut) logrus.SetLevel(origLev) }, logbuf } // Gomega matcher helpers func withContainerImageName(matcher gt.GomegaMatcher) gt.GomegaMatcher { return WithTransform(containerImageName, matcher) } func containerImageName(container t.Container) string { return container.ImageName() } func havingRestartingState(expected bool) gt.GomegaMatcher { return WithTransform(func(container t.Container) bool { return container.ContainerInfo().State.Restarting }, Equal(expected)) } func havingRunningState(expected bool) gt.GomegaMatcher { return WithTransform(func(container t.Container) bool { return container.ContainerInfo().State.Running }, Equal(expected)) } ================================================ FILE: pkg/container/container.go ================================================ // Package container contains code related to dealing with docker containers package container import ( "errors" "fmt" "strconv" "strings" "github.com/containrrr/watchtower/internal/util" wt "github.com/containrrr/watchtower/pkg/types" "github.com/sirupsen/logrus" "github.com/docker/docker/api/types" dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/go-connections/nat" ) // NewContainer returns a new Container instance instantiated with the // specified ContainerInfo and ImageInfo structs. func NewContainer(containerInfo *types.ContainerJSON, imageInfo *types.ImageInspect) *Container { return &Container{ containerInfo: containerInfo, imageInfo: imageInfo, } } // Container represents a running Docker container. type Container struct { LinkedToRestarting bool Stale bool containerInfo *types.ContainerJSON imageInfo *types.ImageInspect } // IsLinkedToRestarting returns the current value of the LinkedToRestarting field for the container func (c *Container) IsLinkedToRestarting() bool { return c.LinkedToRestarting } // IsStale returns the current value of the Stale field for the container func (c *Container) IsStale() bool { return c.Stale } // SetLinkedToRestarting sets the LinkedToRestarting field for the container func (c *Container) SetLinkedToRestarting(value bool) { c.LinkedToRestarting = value } // SetStale implements sets the Stale field for the container func (c *Container) SetStale(value bool) { c.Stale = value } // ContainerInfo fetches JSON info for the container func (c Container) ContainerInfo() *types.ContainerJSON { return c.containerInfo } // ID returns the Docker container ID. func (c Container) ID() wt.ContainerID { return wt.ContainerID(c.containerInfo.ID) } // IsRunning returns a boolean flag indicating whether or not the current // container is running. The status is determined by the value of the // container's "State.Running" property. func (c Container) IsRunning() bool { return c.containerInfo.State.Running } // IsRestarting returns a boolean flag indicating whether or not the current // container is restarting. The status is determined by the value of the // container's "State.Restarting" property. func (c Container) IsRestarting() bool { return c.containerInfo.State.Restarting } // Name returns the Docker container name. func (c Container) Name() string { return c.containerInfo.Name } // ImageID returns the ID of the Docker image that was used to start the // container. May cause nil dereference if imageInfo is not set! func (c Container) ImageID() wt.ImageID { return wt.ImageID(c.imageInfo.ID) } // SafeImageID returns the ID of the Docker image that was used to start the container if available, // otherwise returns an empty string func (c Container) SafeImageID() wt.ImageID { if c.imageInfo == nil { return "" } return wt.ImageID(c.imageInfo.ID) } // ImageName returns the name of the Docker image that was used to start the // container. If the original image was specified without a particular tag, the // "latest" tag is assumed. func (c Container) ImageName() string { // Compatibility w/ Zodiac deployments imageName, ok := c.getLabelValue(zodiacLabel) if !ok { imageName = c.containerInfo.Config.Image } if !strings.Contains(imageName, ":") { imageName = fmt.Sprintf("%s:latest", imageName) } return imageName } // Enabled returns the value of the container enabled label and if the label // was set. func (c Container) Enabled() (bool, bool) { rawBool, ok := c.getLabelValue(enableLabel) if !ok { return false, false } parsedBool, err := strconv.ParseBool(rawBool) if err != nil { return false, false } return parsedBool, true } // IsMonitorOnly returns whether the container should only be monitored based on values of // the monitor-only label, the monitor-only argument and the label-take-precedence argument. func (c Container) IsMonitorOnly(params wt.UpdateParams) bool { return c.getContainerOrGlobalBool(params.MonitorOnly, monitorOnlyLabel, params.LabelPrecedence) } // IsNoPull returns whether the image should be pulled based on values of // the no-pull label, the no-pull argument and the label-take-precedence argument. func (c Container) IsNoPull(params wt.UpdateParams) bool { return c.getContainerOrGlobalBool(params.NoPull, noPullLabel, params.LabelPrecedence) } func (c Container) getContainerOrGlobalBool(globalVal bool, label string, contPrecedence bool) bool { if contVal, err := c.getBoolLabelValue(label); err != nil { if !errors.Is(err, errorLabelNotFound) { logrus.WithField("error", err).WithField("label", label).Warn("Failed to parse label value") } return globalVal } else { if contPrecedence { return contVal } else { return contVal || globalVal } } } // Scope returns the value of the scope UID label and if the label // was set. func (c Container) Scope() (string, bool) { rawString, ok := c.getLabelValue(scope) if !ok { return "", false } return rawString, true } // Links returns a list containing the names of all the containers to which // this container is linked. func (c Container) Links() []string { var links []string dependsOnLabelValue := c.getLabelValueOrEmpty(dependsOnLabel) if dependsOnLabelValue != "" { for _, link := range strings.Split(dependsOnLabelValue, ",") { // Since the container names need to start with '/', let's prepend it if it's missing if !strings.HasPrefix(link, "/") { link = "/" + link } links = append(links, link) } return links } if (c.containerInfo != nil) && (c.containerInfo.HostConfig != nil) { for _, link := range c.containerInfo.HostConfig.Links { name := strings.Split(link, ":")[0] links = append(links, name) } // If the container uses another container for networking, it can be considered an implicit link // since the container would stop working if the network supplier were to be recreated networkMode := c.containerInfo.HostConfig.NetworkMode if networkMode.IsContainer() { links = append(links, networkMode.ConnectedContainer()) } } return links } // ToRestart return whether the container should be restarted, either because // is stale or linked to another stale container. func (c Container) ToRestart() bool { return c.Stale || c.LinkedToRestarting } // IsWatchtower returns a boolean flag indicating whether or not the current // container is the watchtower container itself. The watchtower container is // identified by the presence of the "com.centurylinklabs.watchtower" label in // the container metadata. func (c Container) IsWatchtower() bool { return ContainsWatchtowerLabel(c.containerInfo.Config.Labels) } // PreUpdateTimeout checks whether a container has a specific timeout set // for how long the pre-update command is allowed to run. This value is expressed // either as an integer, in minutes, or as 0 which will allow the command/script // to run indefinitely. Users should be cautious with the 0 option, as that // could result in watchtower waiting forever. func (c Container) PreUpdateTimeout() int { var minutes int var err error val := c.getLabelValueOrEmpty(preUpdateTimeoutLabel) minutes, err = strconv.Atoi(val) if err != nil || val == "" { return 1 } return minutes } // PostUpdateTimeout checks whether a container has a specific timeout set // for how long the post-update command is allowed to run. This value is expressed // either as an integer, in minutes, or as 0 which will allow the command/script // to run indefinitely. Users should be cautious with the 0 option, as that // could result in watchtower waiting forever. func (c Container) PostUpdateTimeout() int { var minutes int var err error val := c.getLabelValueOrEmpty(postUpdateTimeoutLabel) minutes, err = strconv.Atoi(val) if err != nil || val == "" { return 1 } return minutes } // StopSignal returns the custom stop signal (if any) that is encoded in the // container's metadata. If the container has not specified a custom stop // signal, the empty string "" is returned. func (c Container) StopSignal() string { return c.getLabelValueOrEmpty(signalLabel) } // GetCreateConfig returns the container's current Config converted into a format // that can be re-submitted to the Docker create API. // // Ideally, we'd just be able to take the ContainerConfig from the old container // and use it as the starting point for creating the new container; however, // the ContainerConfig that comes back from the Inspect call merges the default // configuration (the stuff specified in the metadata for the image itself) // with the overridden configuration (the stuff that you might specify as part // of the "docker run"). // // In order to avoid unintentionally overriding the // defaults in the new image we need to separate the override options from the // default options. To do this we have to compare the ContainerConfig for the // running container with the ContainerConfig from the image that container was // started from. This function returns a ContainerConfig which contains just // the options overridden at runtime. func (c Container) GetCreateConfig() *dockercontainer.Config { config := c.containerInfo.Config hostConfig := c.containerInfo.HostConfig imageConfig := c.imageInfo.Config if config.WorkingDir == imageConfig.WorkingDir { config.WorkingDir = "" } if config.User == imageConfig.User { config.User = "" } if hostConfig.NetworkMode.IsContainer() { config.Hostname = "" } if util.SliceEqual(config.Entrypoint, imageConfig.Entrypoint) { config.Entrypoint = nil if util.SliceEqual(config.Cmd, imageConfig.Cmd) { config.Cmd = nil } } // Clear HEALTHCHECK configuration (if default) if config.Healthcheck != nil && imageConfig.Healthcheck != nil { if util.SliceEqual(config.Healthcheck.Test, imageConfig.Healthcheck.Test) { config.Healthcheck.Test = nil } if config.Healthcheck.Retries == imageConfig.Healthcheck.Retries { config.Healthcheck.Retries = 0 } if config.Healthcheck.Interval == imageConfig.Healthcheck.Interval { config.Healthcheck.Interval = 0 } if config.Healthcheck.Timeout == imageConfig.Healthcheck.Timeout { config.Healthcheck.Timeout = 0 } if config.Healthcheck.StartPeriod == imageConfig.Healthcheck.StartPeriod { config.Healthcheck.StartPeriod = 0 } } config.Env = util.SliceSubtract(config.Env, imageConfig.Env) config.Labels = util.StringMapSubtract(config.Labels, imageConfig.Labels) config.Volumes = util.StructMapSubtract(config.Volumes, imageConfig.Volumes) // subtract ports exposed in image from container for k := range config.ExposedPorts { if _, ok := imageConfig.ExposedPorts[k]; ok { delete(config.ExposedPorts, k) } } for p := range c.containerInfo.HostConfig.PortBindings { config.ExposedPorts[p] = struct{}{} } config.Image = c.ImageName() return config } // GetCreateHostConfig returns the container's current HostConfig with any links // re-written so that they can be re-submitted to the Docker create API. func (c Container) GetCreateHostConfig() *dockercontainer.HostConfig { hostConfig := c.containerInfo.HostConfig for i, link := range hostConfig.Links { name := link[0:strings.Index(link, ":")] alias := link[strings.LastIndex(link, "/"):] hostConfig.Links[i] = fmt.Sprintf("%s:%s", name, alias) } return hostConfig } // HasImageInfo returns whether image information could be retrieved for the container func (c Container) HasImageInfo() bool { return c.imageInfo != nil } // ImageInfo fetches the ImageInspect data of the current container func (c Container) ImageInfo() *types.ImageInspect { return c.imageInfo } // VerifyConfiguration checks the container and image configurations for nil references to make sure // that the container can be recreated once deleted func (c Container) VerifyConfiguration() error { if c.imageInfo == nil { return errorNoImageInfo } containerInfo := c.ContainerInfo() if containerInfo == nil { return errorNoContainerInfo } containerConfig := containerInfo.Config if containerConfig == nil { return errorInvalidConfig } hostConfig := containerInfo.HostConfig if hostConfig == nil { return errorInvalidConfig } // Instead of returning an error here, we just create an empty map // This should allow for updating containers where the exposed ports are missing if len(hostConfig.PortBindings) > 0 && containerConfig.ExposedPorts == nil { containerConfig.ExposedPorts = make(map[nat.Port]struct{}) } return nil } ================================================ FILE: pkg/container/container_mock_test.go ================================================ package container import ( "github.com/docker/docker/api/types" dockerContainer "github.com/docker/docker/api/types/container" "github.com/docker/go-connections/nat" ) type MockContainerUpdate func(*types.ContainerJSON, *types.ImageInspect) func MockContainer(updates ...MockContainerUpdate) *Container { containerInfo := types.ContainerJSON{ ContainerJSONBase: &types.ContainerJSONBase{ ID: "container_id", Image: "image", Name: "test-containrrr", HostConfig: &dockerContainer.HostConfig{}, }, Config: &dockerContainer.Config{ Labels: map[string]string{}, }, } image := types.ImageInspect{ ID: "image_id", Config: &dockerContainer.Config{}, } for _, update := range updates { update(&containerInfo, &image) } return NewContainer(&containerInfo, &image) } func WithPortBindings(portBindingSources ...string) MockContainerUpdate { return func(c *types.ContainerJSON, i *types.ImageInspect) { portBindings := nat.PortMap{} for _, pbs := range portBindingSources { portBindings[nat.Port(pbs)] = []nat.PortBinding{} } c.HostConfig.PortBindings = portBindings } } func WithImageName(name string) MockContainerUpdate { return func(c *types.ContainerJSON, i *types.ImageInspect) { c.Config.Image = name i.RepoTags = append(i.RepoTags, name) } } func WithLinks(links []string) MockContainerUpdate { return func(c *types.ContainerJSON, i *types.ImageInspect) { c.HostConfig.Links = links } } func WithLabels(labels map[string]string) MockContainerUpdate { return func(c *types.ContainerJSON, i *types.ImageInspect) { c.Config.Labels = labels } } func WithContainerState(state types.ContainerState) MockContainerUpdate { return func(cnt *types.ContainerJSON, img *types.ImageInspect) { cnt.State = &state } } func WithHealthcheck(healthConfig dockerContainer.HealthConfig) MockContainerUpdate { return func(cnt *types.ContainerJSON, img *types.ImageInspect) { cnt.Config.Healthcheck = &healthConfig } } func WithImageHealthcheck(healthConfig dockerContainer.HealthConfig) MockContainerUpdate { return func(cnt *types.ContainerJSON, img *types.ImageInspect) { img.Config.Healthcheck = &healthConfig } } ================================================ FILE: pkg/container/container_suite_test.go ================================================ package container_test import ( "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestContainer(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Container Suite") } ================================================ FILE: pkg/container/container_test.go ================================================ package container import ( "github.com/containrrr/watchtower/pkg/types" dc "github.com/docker/docker/api/types/container" "github.com/docker/go-connections/nat" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) var _ = Describe("the container", func() { Describe("VerifyConfiguration", func() { When("verifying a container with no image info", func() { It("should return an error", func() { c := MockContainer(WithPortBindings()) c.imageInfo = nil err := c.VerifyConfiguration() Expect(err).To(Equal(errorNoImageInfo)) }) }) When("verifying a container with no container info", func() { It("should return an error", func() { c := MockContainer(WithPortBindings()) c.containerInfo = nil err := c.VerifyConfiguration() Expect(err).To(Equal(errorNoContainerInfo)) }) }) When("verifying a container with no config", func() { It("should return an error", func() { c := MockContainer(WithPortBindings()) c.containerInfo.Config = nil err := c.VerifyConfiguration() Expect(err).To(Equal(errorInvalidConfig)) }) }) When("verifying a container with no host config", func() { It("should return an error", func() { c := MockContainer(WithPortBindings()) c.containerInfo.HostConfig = nil err := c.VerifyConfiguration() Expect(err).To(Equal(errorInvalidConfig)) }) }) When("verifying a container with no port bindings", func() { It("should not return an error", func() { c := MockContainer(WithPortBindings()) err := c.VerifyConfiguration() Expect(err).ToNot(HaveOccurred()) }) }) When("verifying a container with port bindings, but no exposed ports", func() { It("should make the config compatible with updating", func() { c := MockContainer(WithPortBindings("80/tcp")) c.containerInfo.Config.ExposedPorts = nil Expect(c.VerifyConfiguration()).To(Succeed()) Expect(c.containerInfo.Config.ExposedPorts).ToNot(BeNil()) Expect(c.containerInfo.Config.ExposedPorts).To(BeEmpty()) }) }) When("verifying a container with port bindings and exposed ports is non-nil", func() { It("should return an error", func() { c := MockContainer(WithPortBindings("80/tcp")) c.containerInfo.Config.ExposedPorts = map[nat.Port]struct{}{"80/tcp": {}} err := c.VerifyConfiguration() Expect(err).ToNot(HaveOccurred()) }) }) }) Describe("GetCreateConfig", func() { When("container healthcheck config is equal to image config", func() { It("should return empty healthcheck values", func() { c := MockContainer(WithHealthcheck(dc.HealthConfig{ Test: []string{"/usr/bin/sleep", "1s"}, }), WithImageHealthcheck(dc.HealthConfig{ Test: []string{"/usr/bin/sleep", "1s"}, })) Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{})) c = MockContainer(WithHealthcheck(dc.HealthConfig{ Timeout: 30, }), WithImageHealthcheck(dc.HealthConfig{ Timeout: 30, })) Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{})) c = MockContainer(WithHealthcheck(dc.HealthConfig{ StartPeriod: 30, }), WithImageHealthcheck(dc.HealthConfig{ StartPeriod: 30, })) Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{})) c = MockContainer(WithHealthcheck(dc.HealthConfig{ Retries: 30, }), WithImageHealthcheck(dc.HealthConfig{ Retries: 30, })) Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{})) }) }) When("container healthcheck config is different to image config", func() { It("should return the container healthcheck values", func() { c := MockContainer(WithHealthcheck(dc.HealthConfig{ Test: []string{"/usr/bin/sleep", "1s"}, Interval: 30, Timeout: 30, StartPeriod: 10, Retries: 2, }), WithImageHealthcheck(dc.HealthConfig{ Test: []string{"/usr/bin/sleep", "10s"}, Interval: 10, Timeout: 60, StartPeriod: 30, Retries: 10, })) Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{ Test: []string{"/usr/bin/sleep", "1s"}, Interval: 30, Timeout: 30, StartPeriod: 10, Retries: 2, })) }) }) When("container healthcheck config is empty", func() { It("should not panic", func() { c := MockContainer(WithImageHealthcheck(dc.HealthConfig{ Test: []string{"/usr/bin/sleep", "10s"}, Interval: 10, Timeout: 60, StartPeriod: 30, Retries: 10, })) Expect(c.GetCreateConfig().Healthcheck).To(BeNil()) }) }) When("container image healthcheck config is empty", func() { It("should not panic", func() { c := MockContainer(WithHealthcheck(dc.HealthConfig{ Test: []string{"/usr/bin/sleep", "1s"}, Interval: 30, Timeout: 30, StartPeriod: 10, Retries: 2, })) Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{ Test: []string{"/usr/bin/sleep", "1s"}, Interval: 30, Timeout: 30, StartPeriod: 10, Retries: 2, })) }) }) }) When("asked for metadata", func() { var c *Container BeforeEach(func() { c = MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.enable": "true", "com.centurylinklabs.watchtower": "true", })) }) It("should return its name on calls to .Name()", func() { name := c.Name() Expect(name).To(Equal("test-containrrr")) Expect(name).NotTo(Equal("wrong-name")) }) It("should return its ID on calls to .ID()", func() { id := c.ID() Expect(id).To(BeEquivalentTo("container_id")) Expect(id).NotTo(BeEquivalentTo("wrong-id")) }) It("should return true, true if enabled on calls to .Enabled()", func() { enabled, exists := c.Enabled() Expect(enabled).To(BeTrue()) Expect(exists).To(BeTrue()) }) It("should return false, true if present but not true on calls to .Enabled()", func() { c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "false"})) enabled, exists := c.Enabled() Expect(enabled).To(BeFalse()) Expect(exists).To(BeTrue()) }) It("should return false, false if not present on calls to .Enabled()", func() { c = MockContainer(WithLabels(map[string]string{"lol": "false"})) enabled, exists := c.Enabled() Expect(enabled).To(BeFalse()) Expect(exists).To(BeFalse()) }) It("should return false, false if present but not parsable .Enabled()", func() { c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "falsy"})) enabled, exists := c.Enabled() Expect(enabled).To(BeFalse()) Expect(exists).To(BeFalse()) }) When("checking if its a watchtower instance", func() { It("should return true if the label is set to true", func() { isWatchtower := c.IsWatchtower() Expect(isWatchtower).To(BeTrue()) }) It("should return false if the label is present but set to false", func() { c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower": "false"})) isWatchtower := c.IsWatchtower() Expect(isWatchtower).To(BeFalse()) }) It("should return false if the label is not present", func() { c = MockContainer(WithLabels(map[string]string{"funny.label": "false"})) isWatchtower := c.IsWatchtower() Expect(isWatchtower).To(BeFalse()) }) It("should return false if there are no labels", func() { c = MockContainer(WithLabels(map[string]string{})) isWatchtower := c.IsWatchtower() Expect(isWatchtower).To(BeFalse()) }) }) When("fetching the custom stop signal", func() { It("should return the signal if its set", func() { c = MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.stop-signal": "SIGKILL", })) stopSignal := c.StopSignal() Expect(stopSignal).To(Equal("SIGKILL")) }) It("should return an empty string if its not set", func() { c = MockContainer(WithLabels(map[string]string{})) stopSignal := c.StopSignal() Expect(stopSignal).To(Equal("")) }) }) When("fetching the image name", func() { When("the zodiac label is present", func() { It("should fetch the image name from it", func() { c = MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.zodiac.original-image": "the-original-image", })) imageName := c.ImageName() Expect(imageName).To(Equal(imageName)) }) }) It("should return the image name", func() { name := "image-name:3" c = MockContainer(WithImageName(name)) imageName := c.ImageName() Expect(imageName).To(Equal(name)) }) It("should assume latest if no tag is supplied", func() { name := "image-name" c = MockContainer(WithImageName(name)) imageName := c.ImageName() Expect(imageName).To(Equal(name + ":latest")) }) }) When("fetching container links", func() { When("the depends on label is present", func() { It("should fetch depending containers from it", func() { c = MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.depends-on": "postgres", })) links := c.Links() Expect(links).To(SatisfyAll(ContainElement("/postgres"), HaveLen(1))) }) It("should fetch depending containers if there are many", func() { c = MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.depends-on": "postgres,redis", })) links := c.Links() Expect(links).To(SatisfyAll(ContainElement("/postgres"), ContainElement("/redis"), HaveLen(2))) }) It("should only add slashes to names when they are missing", func() { c = MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.depends-on": "/postgres,redis", })) links := c.Links() Expect(links).To(SatisfyAll(ContainElement("/postgres"), ContainElement("/redis"))) }) It("should fetch depending containers if label is blank", func() { c = MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.depends-on": "", })) links := c.Links() Expect(links).To(HaveLen(0)) }) }) When("the depends on label is not present", func() { It("should fetch depending containers from host config links", func() { c = MockContainer(WithLinks([]string{ "redis:test-containrrr", "postgres:test-containrrr", })) links := c.Links() Expect(links).To(SatisfyAll(ContainElement("redis"), ContainElement("postgres"), HaveLen(2))) }) }) }) When("checking no-pull label", func() { When("no-pull argument is not set", func() { When("no-pull label is true", func() { c := MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.no-pull": "true", })) It("should return true", func() { Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(true)) }) }) When("no-pull label is false", func() { c := MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.no-pull": "false", })) It("should return false", func() { Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(false)) }) }) When("no-pull label is set to an invalid value", func() { c := MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.no-pull": "maybe", })) It("should return false", func() { Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(false)) }) }) When("no-pull label is unset", func() { c = MockContainer(WithLabels(map[string]string{})) It("should return false", func() { Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(false)) }) }) }) When("no-pull argument is set to true", func() { When("no-pull label is true", func() { c := MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.no-pull": "true", })) It("should return true", func() { Expect(c.IsNoPull(types.UpdateParams{NoPull: true})).To(Equal(true)) }) }) When("no-pull label is false", func() { c := MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.no-pull": "false", })) It("should return true", func() { Expect(c.IsNoPull(types.UpdateParams{NoPull: true})).To(Equal(true)) }) }) When("label-take-precedence argument is set to true", func() { When("no-pull label is true", func() { c := MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.no-pull": "true", })) It("should return true", func() { Expect(c.IsNoPull(types.UpdateParams{LabelPrecedence: true, NoPull: true})).To(Equal(true)) }) }) When("no-pull label is false", func() { c := MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.no-pull": "false", })) It("should return false", func() { Expect(c.IsNoPull(types.UpdateParams{LabelPrecedence: true, NoPull: true})).To(Equal(false)) }) }) }) }) }) When("there is a pre or post update timeout", func() { It("should return minute values", func() { c = MockContainer(WithLabels(map[string]string{ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "3", "com.centurylinklabs.watchtower.lifecycle.post-update-timeout": "5", })) preTimeout := c.PreUpdateTimeout() Expect(preTimeout).To(Equal(3)) postTimeout := c.PostUpdateTimeout() Expect(postTimeout).To(Equal(5)) }) }) }) }) ================================================ FILE: pkg/container/errors.go ================================================ package container import "errors" var errorNoImageInfo = errors.New("no available image info") var errorNoContainerInfo = errors.New("no available container info") var errorInvalidConfig = errors.New("container configuration missing or invalid") var errorLabelNotFound = errors.New("label was not found in container") ================================================ FILE: pkg/container/metadata.go ================================================ package container import "strconv" const ( watchtowerLabel = "com.centurylinklabs.watchtower" signalLabel = "com.centurylinklabs.watchtower.stop-signal" enableLabel = "com.centurylinklabs.watchtower.enable" monitorOnlyLabel = "com.centurylinklabs.watchtower.monitor-only" noPullLabel = "com.centurylinklabs.watchtower.no-pull" dependsOnLabel = "com.centurylinklabs.watchtower.depends-on" zodiacLabel = "com.centurylinklabs.zodiac.original-image" scope = "com.centurylinklabs.watchtower.scope" preCheckLabel = "com.centurylinklabs.watchtower.lifecycle.pre-check" postCheckLabel = "com.centurylinklabs.watchtower.lifecycle.post-check" preUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update" postUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.post-update" preUpdateTimeoutLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout" postUpdateTimeoutLabel = "com.centurylinklabs.watchtower.lifecycle.post-update-timeout" ) // GetLifecyclePreCheckCommand returns the pre-check command set in the container metadata or an empty string func (c Container) GetLifecyclePreCheckCommand() string { return c.getLabelValueOrEmpty(preCheckLabel) } // GetLifecyclePostCheckCommand returns the post-check command set in the container metadata or an empty string func (c Container) GetLifecyclePostCheckCommand() string { return c.getLabelValueOrEmpty(postCheckLabel) } // GetLifecyclePreUpdateCommand returns the pre-update command set in the container metadata or an empty string func (c Container) GetLifecyclePreUpdateCommand() string { return c.getLabelValueOrEmpty(preUpdateLabel) } // GetLifecyclePostUpdateCommand returns the post-update command set in the container metadata or an empty string func (c Container) GetLifecyclePostUpdateCommand() string { return c.getLabelValueOrEmpty(postUpdateLabel) } // ContainsWatchtowerLabel takes a map of labels and values and tells // the consumer whether it contains a valid watchtower instance label func ContainsWatchtowerLabel(labels map[string]string) bool { val, ok := labels[watchtowerLabel] return ok && val == "true" } func (c Container) getLabelValueOrEmpty(label string) string { if val, ok := c.containerInfo.Config.Labels[label]; ok { return val } return "" } func (c Container) getLabelValue(label string) (string, bool) { val, ok := c.containerInfo.Config.Labels[label] return val, ok } func (c Container) getBoolLabelValue(label string) (bool, error) { if strVal, ok := c.containerInfo.Config.Labels[label]; ok { value, err := strconv.ParseBool(strVal) return value, err } return false, errorLabelNotFound } ================================================ FILE: pkg/container/mocks/ApiServer.go ================================================ package mocks import ( "encoding/json" "fmt" "github.com/onsi/ginkgo" "net/http" "net/url" "os" "path/filepath" "strings" t "github.com/containrrr/watchtower/pkg/types" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" O "github.com/onsi/gomega" "github.com/onsi/gomega/ghttp" ) func getMockJSONFile(relPath string) ([]byte, error) { absPath, _ := filepath.Abs(relPath) buf, err := os.ReadFile(absPath) if err != nil { return nil, fmt.Errorf("mock JSON file %q not found: %e", absPath, err) } return buf, nil } // RespondWithJSONFile handles a request by returning the contents of the supplied file func RespondWithJSONFile(relPath string, statusCode int, optionalHeader ...http.Header) http.HandlerFunc { handler, err := respondWithJSONFile(relPath, statusCode, optionalHeader...) O.ExpectWithOffset(1, err).ShouldNot(O.HaveOccurred()) return handler } func respondWithJSONFile(relPath string, statusCode int, optionalHeader ...http.Header) (http.HandlerFunc, error) { buf, err := getMockJSONFile(relPath) if err != nil { return nil, err } return ghttp.RespondWith(statusCode, buf, optionalHeader...), nil } // GetContainerHandlers returns the handlers serving lookups for the supplied container mock files func GetContainerHandlers(containerRefs ...*ContainerRef) []http.HandlerFunc { handlers := make([]http.HandlerFunc, 0, len(containerRefs)*3) for _, containerRef := range containerRefs { handlers = append(handlers, getContainerFileHandler(containerRef)) // Also append any containers that the container references, if any for _, ref := range containerRef.references { handlers = append(handlers, getContainerFileHandler(ref)) } // Also append the image request since that will be called for every container handlers = append(handlers, getImageHandler(containerRef.image.id, RespondWithJSONFile(containerRef.image.getFileName(), http.StatusOK), )) } return handlers } func createFilterArgs(statuses []string) filters.Args { args := filters.NewArgs() for _, status := range statuses { args.Add("status", status) } return args } var defaultImage = imageRef{ // watchtower id: t.ImageID("sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa"), file: "default", } var Watchtower = ContainerRef{ name: "watchtower", id: "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134", image: &defaultImage, } var Stopped = ContainerRef{ name: "stopped", id: "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65", image: &defaultImage, } var Running = ContainerRef{ name: "running", id: "b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008", image: &imageRef{ // portainer id: t.ImageID("sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd"), file: "running", }, } var Restarting = ContainerRef{ name: "restarting", id: "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67", image: &defaultImage, } var netSupplierOK = ContainerRef{ id: "25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2", name: "net_supplier", image: &imageRef{ // gluetun id: t.ImageID("sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51"), file: "net_producer", }, } var netSupplierNotFound = ContainerRef{ id: NetSupplierNotFoundID, name: netSupplierOK.name, isMissing: true, } // NetConsumerOK is used for testing `container` networking mode // returns a container that consumes an existing supplier container var NetConsumerOK = ContainerRef{ id: "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6", name: "net_consumer", image: &imageRef{ id: t.ImageID("sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8"), // nginx file: "net_consumer", }, references: []*ContainerRef{&netSupplierOK}, } // NetConsumerInvalidSupplier is used for testing `container` networking mode // returns a container that references a supplying container that does not exist var NetConsumerInvalidSupplier = ContainerRef{ id: NetConsumerOK.id, name: "net_consumer-missing_supplier", image: NetConsumerOK.image, references: []*ContainerRef{&netSupplierNotFound}, } const NetSupplierNotFoundID = "badc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc" const NetSupplierContainerName = "/wt-contnet-producer-1" func getContainerFileHandler(cr *ContainerRef) http.HandlerFunc { if cr.isMissing { return containerNotFoundResponse(string(cr.id)) } containerFile, err := cr.getContainerFile() if err != nil { ginkgo.Fail(fmt.Sprintf("Failed to get container mock file: %v", err)) } return getContainerHandler( string(cr.id), RespondWithJSONFile(containerFile, http.StatusOK), ) } func getContainerHandler(containerId string, responseHandler http.HandlerFunc) http.HandlerFunc { return ghttp.CombineHandlers( ghttp.VerifyRequest("GET", O.HaveSuffix("/containers/%v/json", containerId)), responseHandler, ) } // GetContainerHandler mocks the GET containers/{id}/json endpoint func GetContainerHandler(containerID string, containerInfo *types.ContainerJSON) http.HandlerFunc { responseHandler := containerNotFoundResponse(containerID) if containerInfo != nil { responseHandler = ghttp.RespondWithJSONEncoded(http.StatusOK, containerInfo) } return getContainerHandler(containerID, responseHandler) } // GetImageHandler mocks the GET images/{id}/json endpoint func GetImageHandler(imageInfo *types.ImageInspect) http.HandlerFunc { return getImageHandler(t.ImageID(imageInfo.ID), ghttp.RespondWithJSONEncoded(http.StatusOK, imageInfo)) } // ListContainersHandler mocks the GET containers/json endpoint, filtering the returned containers based on statuses func ListContainersHandler(statuses ...string) http.HandlerFunc { filterArgs := createFilterArgs(statuses) bytes, err := filterArgs.MarshalJSON() O.ExpectWithOffset(1, err).ShouldNot(O.HaveOccurred()) query := url.Values{ "filters": []string{string(bytes)}, } return ghttp.CombineHandlers( ghttp.VerifyRequest("GET", O.HaveSuffix("containers/json"), query.Encode()), respondWithFilteredContainers(filterArgs), ) } func respondWithFilteredContainers(filters filters.Args) http.HandlerFunc { containersJSON, err := getMockJSONFile("./mocks/data/containers.json") O.ExpectWithOffset(2, err).ShouldNot(O.HaveOccurred()) var filteredContainers []types.Container var containers []types.Container O.ExpectWithOffset(2, json.Unmarshal(containersJSON, &containers)).To(O.Succeed()) for _, v := range containers { for _, key := range filters.Get("status") { if v.State == key { filteredContainers = append(filteredContainers, v) } } } return ghttp.RespondWithJSONEncoded(http.StatusOK, filteredContainers) } func getImageHandler(imageId t.ImageID, responseHandler http.HandlerFunc) http.HandlerFunc { return ghttp.CombineHandlers( ghttp.VerifyRequest("GET", O.HaveSuffix("/images/%s/json", imageId)), responseHandler, ) } // KillContainerHandler mocks the POST containers/{id}/kill endpoint func KillContainerHandler(containerID string, found FoundStatus) http.HandlerFunc { responseHandler := noContentStatusResponse if !found { responseHandler = containerNotFoundResponse(containerID) } return ghttp.CombineHandlers( ghttp.VerifyRequest("POST", O.HaveSuffix("containers/%s/kill", containerID)), responseHandler, ) } // RemoveContainerHandler mocks the DELETE containers/{id} endpoint func RemoveContainerHandler(containerID string, found FoundStatus) http.HandlerFunc { responseHandler := noContentStatusResponse if !found { responseHandler = containerNotFoundResponse(containerID) } return ghttp.CombineHandlers( ghttp.VerifyRequest("DELETE", O.HaveSuffix("containers/%s", containerID)), responseHandler, ) } func containerNotFoundResponse(containerID string) http.HandlerFunc { return ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{message: "No such container: " + string(containerID)}) } var noContentStatusResponse = ghttp.RespondWith(http.StatusNoContent, nil) type FoundStatus bool const ( Found FoundStatus = true Missing FoundStatus = false ) // RemoveImageHandler mocks the DELETE images/ID endpoint, simulating removal of the given imagesWithParents func RemoveImageHandler(imagesWithParents map[string][]string) http.HandlerFunc { return ghttp.CombineHandlers( ghttp.VerifyRequest("DELETE", O.MatchRegexp("/images/.*")), func(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, `/`) image := parts[len(parts)-1] if parents, found := imagesWithParents[image]; found { items := []types.ImageDeleteResponseItem{ {Untagged: image}, {Deleted: image}, } for _, parent := range parents { items = append(items, types.ImageDeleteResponseItem{Deleted: parent}) } ghttp.RespondWithJSONEncoded(http.StatusOK, items)(w, r) } else { ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{ message: "Something went wrong.", })(w, r) } }, ) } ================================================ FILE: pkg/container/mocks/FilterableContainer.go ================================================ package mocks import mock "github.com/stretchr/testify/mock" // FilterableContainer is an autogenerated mock type for the FilterableContainer type type FilterableContainer struct { mock.Mock } // Enabled provides a mock function with given fields: func (_m *FilterableContainer) Enabled() (bool, bool) { ret := _m.Called() var r0 bool if rf, ok := ret.Get(0).(func() bool); ok { r0 = rf() } else { r0 = ret.Get(0).(bool) } var r1 bool if rf, ok := ret.Get(1).(func() bool); ok { r1 = rf() } else { r1 = ret.Get(1).(bool) } return r0, r1 } // IsWatchtower provides a mock function with given fields: func (_m *FilterableContainer) IsWatchtower() bool { ret := _m.Called() var r0 bool if rf, ok := ret.Get(0).(func() bool); ok { r0 = rf() } else { r0 = ret.Get(0).(bool) } return r0 } // Name provides a mock function with given fields: func (_m *FilterableContainer) Name() string { ret := _m.Called() var r0 string if rf, ok := ret.Get(0).(func() string); ok { r0 = rf() } else { r0 = ret.Get(0).(string) } return r0 } // Scope provides a mock function with given fields: func (_m *FilterableContainer) Scope() (string, bool) { ret := _m.Called() var r0 string if rf, ok := ret.Get(0).(func() string); ok { r0 = rf() } else { r0 = ret.Get(0).(string) } var r1 bool if rf, ok := ret.Get(1).(func() bool); ok { r1 = rf() } else { r1 = ret.Get(1).(bool) } return r0, r1 } // ImageName provides a mock function with given fields: func (_m *FilterableContainer) ImageName() string { ret := _m.Called() var r0 string if rf, ok := ret.Get(0).(func() string); ok { r0 = rf() } else { r0 = ret.Get(0).(string) } return r0 } ================================================ FILE: pkg/container/mocks/container_ref.go ================================================ package mocks import ( "fmt" "os" t "github.com/containrrr/watchtower/pkg/types" ) type imageRef struct { id t.ImageID file string } func (ir *imageRef) getFileName() string { return fmt.Sprintf("./mocks/data/image_%v.json", ir.file) } type ContainerRef struct { name string id t.ContainerID image *imageRef file string references []*ContainerRef isMissing bool } func (cr *ContainerRef) getContainerFile() (containerFile string, err error) { file := cr.file if file == "" { file = cr.name } containerFile = fmt.Sprintf("./mocks/data/container_%v.json", file) _, err = os.Stat(containerFile) return containerFile, err } func (cr *ContainerRef) ContainerID() t.ContainerID { return cr.id } ================================================ FILE: pkg/container/mocks/data/container_net_consumer-missing_supplier.json ================================================ { "Id": "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6", "Created": "2023-07-25T14:55:14.69155887Z", "Path": "/docker-entrypoint.sh", "Args": [ "nginx", "-g", "daemon off;" ], "State": { "Status": "running", "Running": true, "Paused": false, "Restarting": false, "OOMKilled": false, "Dead": false, "Pid": 3743, "ExitCode": 0, "Error": "", "StartedAt": "2023-07-25T14:55:15.299654437Z", "FinishedAt": "0001-01-01T00:00:00Z" }, "Image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8", "ResolvConfPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/resolv.conf", "HostnamePath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hostname", "HostsPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hosts", "LogPath": "/var/lib/docker/containers/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6-json.log", "Name": "/wt-contnet-consumer-1", "RestartCount": 0, "Driver": "overlay2", "Platform": "linux", "MountLabel": "", "ProcessLabel": "", "AppArmorProfile": "", "ExecIDs": null, "HostConfig": { "Binds": null, "ContainerIDFile": "", "LogConfig": { "Type": "json-file", "Config": {} }, "NetworkMode": "container:badc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc", "PortBindings": {}, "RestartPolicy": { "Name": "", "MaximumRetryCount": 0 }, "AutoRemove": false, "VolumeDriver": "", "VolumesFrom": null, "ConsoleSize": [ 0, 0 ], "CapAdd": null, "CapDrop": null, "CgroupnsMode": "host", "Dns": null, "DnsOptions": null, "DnsSearch": null, "ExtraHosts": [], "GroupAdd": null, "IpcMode": "private", "Cgroup": "", "Links": null, "OomScoreAdj": 0, "PidMode": "", "Privileged": false, "PublishAllPorts": false, "ReadonlyRootfs": false, "SecurityOpt": null, "UTSMode": "", "UsernsMode": "", "ShmSize": 67108864, "Runtime": "runc", "Isolation": "", "CpuShares": 0, "Memory": 0, "NanoCpus": 0, "CgroupParent": "", "BlkioWeight": 0, "BlkioWeightDevice": null, "BlkioDeviceReadBps": null, "BlkioDeviceWriteBps": null, "BlkioDeviceReadIOps": null, "BlkioDeviceWriteIOps": null, "CpuPeriod": 0, "CpuQuota": 0, "CpuRealtimePeriod": 0, "CpuRealtimeRuntime": 0, "CpusetCpus": "", "CpusetMems": "", "Devices": null, "DeviceCgroupRules": null, "DeviceRequests": null, "MemoryReservation": 0, "MemorySwap": 0, "MemorySwappiness": null, "OomKillDisable": false, "PidsLimit": null, "Ulimits": null, "CpuCount": 0, "CpuPercent": 0, "IOMaximumIOps": 0, "IOMaximumBandwidth": 0, "MaskedPaths": [ "/proc/asound", "/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi", "/sys/firmware" ], "ReadonlyPaths": [ "/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger" ] }, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2-init/diff:/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/diff:/var/lib/docker/overlay2/09785ba17f27c783ef8b44f369f9aac0ca936000b57abf22b3c54d1e6eb8e27b/diff:/var/lib/docker/overlay2/6f8acd64ae44fd4d14bcb90c105eceba46854aa3985b5b6b317bcc5692cfc286/diff:/var/lib/docker/overlay2/73d41c15edb21c5f12cf53e313f48b5da55283aafc77d35b7bc662241879d7e7/diff:/var/lib/docker/overlay2/d97b55f3d966ae031492369a98e9e00d2bd31e520290fe2034e0a2b1ed77c91e/diff:/var/lib/docker/overlay2/053e9ca65c6b64cb9d98a812ff7488c7e77938b4fb8e0c4d2ad7f8ec235f0f20/diff", "MergedDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/merged", "UpperDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/diff", "WorkDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/work" }, "Name": "overlay2" }, "Mounts": [], "Config": { "Hostname": "25e75393800b", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": true, "AttachStderr": true, "ExposedPorts": { "80/tcp": {} }, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "NGINX_VERSION=1.23.3", "NJS_VERSION=0.7.9", "PKG_RELEASE=1~bullseye" ], "Cmd": [ "nginx", "-g", "daemon off;" ], "Image": "nginx", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/docker-entrypoint.sh" ], "OnBuild": null, "Labels": { "com.docker.compose.config-hash": "8bb0e1c8c61f6d495840ba9133ebfb1e4ffda3e1adb701a011b03951848bb9fa", "com.docker.compose.container-number": "1", "com.docker.compose.depends_on": "producer:service_started:false", "com.docker.compose.image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8", "com.docker.compose.oneoff": "False", "com.docker.compose.project": "wt-contnet", "com.docker.compose.project.config_files": "/tmp/wt-contnet/docker-compose.yaml", "com.docker.compose.project.working_dir": "/tmp/wt-contnet", "com.docker.compose.replace": "07bb70608f96f577aa02b9f317500e23e691c94eb099f6fb52301dfb031d0668", "com.docker.compose.service": "consumer", "com.docker.compose.version": "2.19.1", "desktop.docker.io/wsl-distro": "Ubuntu", "maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e" }, "StopSignal": "SIGQUIT" }, "NetworkSettings": { "Bridge": "", "SandboxID": "", "HairpinMode": false, "LinkLocalIPv6Address": "", "LinkLocalIPv6PrefixLen": 0, "Ports": {}, "SandboxKey": "", "SecondaryIPAddresses": null, "SecondaryIPv6Addresses": null, "EndpointID": "", "Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "MacAddress": "", "Networks": {} } } ================================================ FILE: pkg/container/mocks/data/container_net_consumer.json ================================================ { "Id": "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6", "Created": "2023-07-25T14:55:14.69155887Z", "Path": "/docker-entrypoint.sh", "Args": [ "nginx", "-g", "daemon off;" ], "State": { "Status": "running", "Running": true, "Paused": false, "Restarting": false, "OOMKilled": false, "Dead": false, "Pid": 3743, "ExitCode": 0, "Error": "", "StartedAt": "2023-07-25T14:55:15.299654437Z", "FinishedAt": "0001-01-01T00:00:00Z" }, "Image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8", "ResolvConfPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/resolv.conf", "HostnamePath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hostname", "HostsPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hosts", "LogPath": "/var/lib/docker/containers/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6-json.log", "Name": "/wt-contnet-consumer-1", "RestartCount": 0, "Driver": "overlay2", "Platform": "linux", "MountLabel": "", "ProcessLabel": "", "AppArmorProfile": "", "ExecIDs": null, "HostConfig": { "Binds": null, "ContainerIDFile": "", "LogConfig": { "Type": "json-file", "Config": {} }, "NetworkMode": "container:25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2", "PortBindings": {}, "RestartPolicy": { "Name": "", "MaximumRetryCount": 0 }, "AutoRemove": false, "VolumeDriver": "", "VolumesFrom": null, "ConsoleSize": [ 0, 0 ], "CapAdd": null, "CapDrop": null, "CgroupnsMode": "host", "Dns": null, "DnsOptions": null, "DnsSearch": null, "ExtraHosts": [], "GroupAdd": null, "IpcMode": "private", "Cgroup": "", "Links": null, "OomScoreAdj": 0, "PidMode": "", "Privileged": false, "PublishAllPorts": false, "ReadonlyRootfs": false, "SecurityOpt": null, "UTSMode": "", "UsernsMode": "", "ShmSize": 67108864, "Runtime": "runc", "Isolation": "", "CpuShares": 0, "Memory": 0, "NanoCpus": 0, "CgroupParent": "", "BlkioWeight": 0, "BlkioWeightDevice": null, "BlkioDeviceReadBps": null, "BlkioDeviceWriteBps": null, "BlkioDeviceReadIOps": null, "BlkioDeviceWriteIOps": null, "CpuPeriod": 0, "CpuQuota": 0, "CpuRealtimePeriod": 0, "CpuRealtimeRuntime": 0, "CpusetCpus": "", "CpusetMems": "", "Devices": null, "DeviceCgroupRules": null, "DeviceRequests": null, "MemoryReservation": 0, "MemorySwap": 0, "MemorySwappiness": null, "OomKillDisable": false, "PidsLimit": null, "Ulimits": null, "CpuCount": 0, "CpuPercent": 0, "IOMaximumIOps": 0, "IOMaximumBandwidth": 0, "MaskedPaths": [ "/proc/asound", "/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi", "/sys/firmware" ], "ReadonlyPaths": [ "/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger" ] }, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2-init/diff:/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/diff:/var/lib/docker/overlay2/09785ba17f27c783ef8b44f369f9aac0ca936000b57abf22b3c54d1e6eb8e27b/diff:/var/lib/docker/overlay2/6f8acd64ae44fd4d14bcb90c105eceba46854aa3985b5b6b317bcc5692cfc286/diff:/var/lib/docker/overlay2/73d41c15edb21c5f12cf53e313f48b5da55283aafc77d35b7bc662241879d7e7/diff:/var/lib/docker/overlay2/d97b55f3d966ae031492369a98e9e00d2bd31e520290fe2034e0a2b1ed77c91e/diff:/var/lib/docker/overlay2/053e9ca65c6b64cb9d98a812ff7488c7e77938b4fb8e0c4d2ad7f8ec235f0f20/diff", "MergedDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/merged", "UpperDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/diff", "WorkDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/work" }, "Name": "overlay2" }, "Mounts": [], "Config": { "Hostname": "25e75393800b", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": true, "AttachStderr": true, "ExposedPorts": { "80/tcp": {} }, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "NGINX_VERSION=1.23.3", "NJS_VERSION=0.7.9", "PKG_RELEASE=1~bullseye" ], "Cmd": [ "nginx", "-g", "daemon off;" ], "Image": "nginx", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/docker-entrypoint.sh" ], "OnBuild": null, "Labels": { "com.docker.compose.config-hash": "8bb0e1c8c61f6d495840ba9133ebfb1e4ffda3e1adb701a011b03951848bb9fa", "com.docker.compose.container-number": "1", "com.docker.compose.depends_on": "producer:service_started:false", "com.docker.compose.image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8", "com.docker.compose.oneoff": "False", "com.docker.compose.project": "wt-contnet", "com.docker.compose.project.config_files": "/tmp/wt-contnet/docker-compose.yaml", "com.docker.compose.project.working_dir": "/tmp/wt-contnet", "com.docker.compose.replace": "07bb70608f96f577aa02b9f317500e23e691c94eb099f6fb52301dfb031d0668", "com.docker.compose.service": "consumer", "com.docker.compose.version": "2.19.1", "desktop.docker.io/wsl-distro": "Ubuntu", "maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e" }, "StopSignal": "SIGQUIT" }, "NetworkSettings": { "Bridge": "", "SandboxID": "", "HairpinMode": false, "LinkLocalIPv6Address": "", "LinkLocalIPv6PrefixLen": 0, "Ports": {}, "SandboxKey": "", "SecondaryIPAddresses": null, "SecondaryIPv6Addresses": null, "EndpointID": "", "Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "MacAddress": "", "Networks": {} } } ================================================ FILE: pkg/container/mocks/data/container_net_supplier.json ================================================ { "Id": "25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2", "Created": "2023-07-25T14:55:14.595662628Z", "Path": "/gluetun-entrypoint", "Args": [], "State": { "Status": "running", "Running": true, "Paused": false, "Restarting": false, "OOMKilled": false, "Dead": false, "Pid": 3648, "ExitCode": 0, "Error": "", "StartedAt": "2023-07-25T14:55:15.193430103Z", "FinishedAt": "0001-01-01T00:00:00Z", "Health": { "Status": "healthy", "FailingStreak": 0, "Log": [ { "Start": "2023-07-25T15:00:32.078491228Z", "End": "2023-07-25T15:00:32.194554876Z", "ExitCode": 0, "Output": "" }, { "Start": "2023-07-25T15:00:37.199245496Z", "End": "2023-07-25T15:00:37.294845687Z", "ExitCode": 0, "Output": "" }, { "Start": "2023-07-25T15:00:42.299676089Z", "End": "2023-07-25T15:00:42.384213818Z", "ExitCode": 0, "Output": "" }, { "Start": "2023-07-25T15:00:47.389142447Z", "End": "2023-07-25T15:00:47.514483294Z", "ExitCode": 0, "Output": "" }, { "Start": "2023-07-25T15:00:52.518770886Z", "End": "2023-07-25T15:00:52.644288742Z", "ExitCode": 0, "Output": "" } ] } }, "Image": "sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51", "ResolvConfPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/resolv.conf", "HostnamePath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hostname", "HostsPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hosts", "LogPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2-json.log", "Name": "/wt-contnet-producer-1", "RestartCount": 0, "Driver": "overlay2", "Platform": "linux", "MountLabel": "", "ProcessLabel": "", "AppArmorProfile": "", "ExecIDs": null, "HostConfig": { "Binds": null, "ContainerIDFile": "", "LogConfig": { "Type": "json-file", "Config": {} }, "NetworkMode": "wt-contnet_default", "PortBindings": {}, "RestartPolicy": { "Name": "", "MaximumRetryCount": 0 }, "AutoRemove": false, "VolumeDriver": "", "VolumesFrom": null, "ConsoleSize": [ 0, 0 ], "CapAdd": [ "NET_ADMIN" ], "CapDrop": null, "CgroupnsMode": "host", "Dns": null, "DnsOptions": null, "DnsSearch": null, "ExtraHosts": [], "GroupAdd": null, "IpcMode": "private", "Cgroup": "", "Links": null, "OomScoreAdj": 0, "PidMode": "", "Privileged": false, "PublishAllPorts": false, "ReadonlyRootfs": false, "SecurityOpt": null, "UTSMode": "", "UsernsMode": "", "ShmSize": 67108864, "Runtime": "runc", "Isolation": "", "CpuShares": 0, "Memory": 0, "NanoCpus": 0, "CgroupParent": "", "BlkioWeight": 0, "BlkioWeightDevice": null, "BlkioDeviceReadBps": null, "BlkioDeviceWriteBps": null, "BlkioDeviceReadIOps": null, "BlkioDeviceWriteIOps": null, "CpuPeriod": 0, "CpuQuota": 0, "CpuRealtimePeriod": 0, "CpuRealtimeRuntime": 0, "CpusetCpus": "", "CpusetMems": "", "Devices": null, "DeviceCgroupRules": null, "DeviceRequests": null, "MemoryReservation": 0, "MemorySwap": 0, "MemorySwappiness": null, "OomKillDisable": false, "PidsLimit": null, "Ulimits": null, "CpuCount": 0, "CpuPercent": 0, "IOMaximumIOps": 0, "IOMaximumBandwidth": 0, "MaskedPaths": [ "/proc/asound", "/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi", "/sys/firmware" ], "ReadonlyPaths": [ "/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger" ] }, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2-init/diff:/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/diff:/var/lib/docker/overlay2/a20c9490a23ee8af51898892d9bf32258d44e0e07f3799475be8e8f273a50f73/diff:/var/lib/docker/overlay2/d4c97f367c37c6ada9de57f438a3e19cc714be2a54a6f582a03de9e42d88b344/diff", "MergedDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2/merged", "UpperDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2/diff", "WorkDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2/work" }, "Name": "overlay2" }, "Mounts": [], "Config": { "Hostname": "25e75393800b", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": true, "AttachStderr": true, "ExposedPorts": { "8000/tcp": {}, "8388/tcp": {}, "8388/udp": {}, "8888/tcp": {} }, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "OPENVPN_PASSWORD=", "SERVER_COUNTRIES=Sweden", "VPN_SERVICE_PROVIDER=nordvpn", "OPENVPN_USER=", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "VPN_TYPE=openvpn", "VPN_ENDPOINT_IP=", "VPN_ENDPOINT_PORT=", "VPN_INTERFACE=tun0", "OPENVPN_PROTOCOL=udp", "OPENVPN_USER_SECRETFILE=/run/secrets/openvpn_user", "OPENVPN_PASSWORD_SECRETFILE=/run/secrets/openvpn_password", "OPENVPN_VERSION=2.5", "OPENVPN_VERBOSITY=1", "OPENVPN_FLAGS=", "OPENVPN_CIPHERS=", "OPENVPN_AUTH=", "OPENVPN_PROCESS_USER=root", "OPENVPN_CUSTOM_CONFIG=", "WIREGUARD_PRIVATE_KEY=", "WIREGUARD_PRESHARED_KEY=", "WIREGUARD_PUBLIC_KEY=", "WIREGUARD_ALLOWED_IPS=", "WIREGUARD_ADDRESSES=", "WIREGUARD_MTU=1400", "WIREGUARD_IMPLEMENTATION=auto", "SERVER_REGIONS=", "SERVER_CITIES=", "SERVER_HOSTNAMES=", "ISP=", "OWNED_ONLY=no", "PRIVATE_INTERNET_ACCESS_OPENVPN_ENCRYPTION_PRESET=", "VPN_PORT_FORWARDING=off", "VPN_PORT_FORWARDING_PROVIDER=", "VPN_PORT_FORWARDING_STATUS_FILE=/tmp/gluetun/forwarded_port", "OPENVPN_CERT=", "OPENVPN_KEY=", "OPENVPN_CLIENTCRT_SECRETFILE=/run/secrets/openvpn_clientcrt", "OPENVPN_CLIENTKEY_SECRETFILE=/run/secrets/openvpn_clientkey", "OPENVPN_ENCRYPTED_KEY=", "OPENVPN_ENCRYPTED_KEY_SECRETFILE=/run/secrets/openvpn_encrypted_key", "OPENVPN_KEY_PASSPHRASE=", "OPENVPN_KEY_PASSPHRASE_SECRETFILE=/run/secrets/openvpn_key_passphrase", "SERVER_NUMBER=", "SERVER_NAMES=", "FREE_ONLY=", "MULTIHOP_ONLY=", "PREMIUM_ONLY=", "FIREWALL=on", "FIREWALL_VPN_INPUT_PORTS=", "FIREWALL_INPUT_PORTS=", "FIREWALL_OUTBOUND_SUBNETS=", "FIREWALL_DEBUG=off", "LOG_LEVEL=info", "HEALTH_SERVER_ADDRESS=127.0.0.1:9999", "HEALTH_TARGET_ADDRESS=cloudflare.com:443", "HEALTH_SUCCESS_WAIT_DURATION=5s", "HEALTH_VPN_DURATION_INITIAL=6s", "HEALTH_VPN_DURATION_ADDITION=5s", "DOT=on", "DOT_PROVIDERS=cloudflare", "DOT_PRIVATE_ADDRESS=127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1/128,fc00::/7,fe80::/10,::ffff:7f00:1/104,::ffff:a00:0/104,::ffff:a9fe:0/112,::ffff:ac10:0/108,::ffff:c0a8:0/112", "DOT_VERBOSITY=1", "DOT_VERBOSITY_DETAILS=0", "DOT_VALIDATION_LOGLEVEL=0", "DOT_CACHING=on", "DOT_IPV6=off", "BLOCK_MALICIOUS=on", "BLOCK_SURVEILLANCE=off", "BLOCK_ADS=off", "UNBLOCK=", "DNS_UPDATE_PERIOD=24h", "DNS_ADDRESS=127.0.0.1", "DNS_KEEP_NAMESERVER=off", "HTTPPROXY=", "HTTPPROXY_LOG=off", "HTTPPROXY_LISTENING_ADDRESS=:8888", "HTTPPROXY_STEALTH=off", "HTTPPROXY_USER=", "HTTPPROXY_PASSWORD=", "HTTPPROXY_USER_SECRETFILE=/run/secrets/httpproxy_user", "HTTPPROXY_PASSWORD_SECRETFILE=/run/secrets/httpproxy_password", "SHADOWSOCKS=off", "SHADOWSOCKS_LOG=off", "SHADOWSOCKS_LISTENING_ADDRESS=:8388", "SHADOWSOCKS_PASSWORD=", "SHADOWSOCKS_PASSWORD_SECRETFILE=/run/secrets/shadowsocks_password", "SHADOWSOCKS_CIPHER=chacha20-ietf-poly1305", "HTTP_CONTROL_SERVER_LOG=on", "HTTP_CONTROL_SERVER_ADDRESS=:8000", "UPDATER_PERIOD=0", "UPDATER_MIN_RATIO=0.8", "UPDATER_VPN_SERVICE_PROVIDERS=", "PUBLICIP_FILE=/tmp/gluetun/ip", "PUBLICIP_PERIOD=12h", "PPROF_ENABLED=no", "PPROF_BLOCK_PROFILE_RATE=0", "PPROF_MUTEX_PROFILE_RATE=0", "PPROF_HTTP_SERVER_ADDRESS=:6060", "VERSION_INFORMATION=on", "TZ=", "PUID=", "PGID=" ], "Cmd": null, "Healthcheck": { "Test": [ "CMD-SHELL", "/gluetun-entrypoint healthcheck" ], "Interval": 5000000000, "Timeout": 5000000000, "StartPeriod": 10000000000, "Retries": 1 }, "Image": "qmcgaw/gluetun", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/gluetun-entrypoint" ], "OnBuild": null, "Labels": { "com.docker.compose.config-hash": "6dc7dc42a86edb47039de3650a9cb9bdcf4866c113b8f9d797722c9dfd20428b", "com.docker.compose.container-number": "1", "com.docker.compose.depends_on": "", "com.docker.compose.image": "sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51", "com.docker.compose.oneoff": "False", "com.docker.compose.project": "wt-contnet", "com.docker.compose.project.config_files": "/tmp/wt-contnet/docker-compose.yaml", "com.docker.compose.project.working_dir": "/tmp/wt-contnet", "com.docker.compose.replace": "9bd1ce000be81819fc915aa60a1674c7573b59a26ac4643ecf427a5732b9785f", "com.docker.compose.service": "producer", "com.docker.compose.version": "2.19.1", "desktop.docker.io/wsl-distro": "Ubuntu", "org.opencontainers.image.authors": "quentin.mcgaw@gmail.com", "org.opencontainers.image.created": "2023-07-22T16:07:05.641Z", "org.opencontainers.image.description": "VPN client in a thin Docker container for multiple VPN providers, written in Go, and using OpenVPN or Wireguard, DNS over TLS, with a few proxy servers built-in.", "org.opencontainers.image.documentation": "https://github.com/qdm12/gluetun", "org.opencontainers.image.licenses": "MIT", "org.opencontainers.image.revision": "eecfb3952f202c0de3867d88e96d80c6b0f48359", "org.opencontainers.image.source": "https://github.com/qdm12/gluetun", "org.opencontainers.image.title": "gluetun", "org.opencontainers.image.url": "https://github.com/qdm12/gluetun", "org.opencontainers.image.version": "latest" } }, "NetworkSettings": { "Bridge": "", "SandboxID": "34a321b64bb1b15f994dfccff0e235f881504f240c2028876ff6683962eaa10e", "HairpinMode": false, "LinkLocalIPv6Address": "", "LinkLocalIPv6PrefixLen": 0, "Ports": { "8000/tcp": null, "8388/tcp": null, "8388/udp": null, "8888/tcp": null }, "SandboxKey": "/var/run/docker/netns/34a321b64bb1", "SecondaryIPAddresses": null, "SecondaryIPv6Addresses": null, "EndpointID": "", "Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "MacAddress": "", "Networks": { "wt-contnet_default": { "IPAMConfig": null, "Links": null, "Aliases": [ "wt-contnet-producer-1", "producer", "25e75393800b" ], "NetworkID": "f0f652a79efc54bcad52aafb4cbcc3b5dce1acaf11b172d8678d25f665faf63d", "EndpointID": "2429c2b5d08db6c986bbd419a52ca4dd352715d80c5aeae04742efb84b0356fc", "Gateway": "172.19.0.1", "IPAddress": "172.19.0.2", "IPPrefixLen": 16, "IPv6Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "MacAddress": "02:42:ac:13:00:02", "DriverOpts": null } } } } ================================================ FILE: pkg/container/mocks/data/container_restarting.json ================================================ { "Id": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67", "Created": "2019-04-10T19:51:22.245041005Z", "Path": "/watchtower", "Args": [], "State": { "Status": "exited", "Running": false, "Paused": false, "Restarting": true, "OOMKilled": false, "Dead": false, "Pid": 0, "ExitCode": 1, "Error": "", "StartedAt": "2019-04-10T19:51:22.918972606Z", "FinishedAt": "2019-04-10T19:52:14.265091583Z" }, "Image": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", "ResolvConfPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/resolv.conf", "HostnamePath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hostname", "HostsPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hosts", "LogPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65-json.log", "Name": "/watchtower-test", "RestartCount": 0, "Driver": "overlay2", "Platform": "linux", "MountLabel": "", "ProcessLabel": "", "AppArmorProfile": "", "ExecIDs": null, "HostConfig": { "Binds": [ "/var/run/docker.sock:/var/run/docker.sock" ], "ContainerIDFile": "", "LogConfig": { "Type": "json-file", "Config": {} }, "NetworkMode": "default", "PortBindings": {}, "RestartPolicy": { "Name": "no", "MaximumRetryCount": 0 }, "AutoRemove": false, "VolumeDriver": "", "VolumesFrom": null, "CapAdd": null, "CapDrop": null, "Dns": [], "DnsOptions": [], "DnsSearch": [], "ExtraHosts": null, "GroupAdd": null, "IpcMode": "shareable", "Cgroup": "", "Links": null, "OomScoreAdj": 0, "PidMode": "", "Privileged": false, "PublishAllPorts": false, "ReadonlyRootfs": false, "SecurityOpt": null, "UTSMode": "", "UsernsMode": "", "ShmSize": 67108864, "Runtime": "runc", "ConsoleSize": [ 0, 0 ], "Isolation": "", "CpuShares": 0, "Memory": 0, "NanoCpus": 0, "CgroupParent": "", "BlkioWeight": 0, "BlkioWeightDevice": [], "BlkioDeviceReadBps": null, "BlkioDeviceWriteBps": null, "BlkioDeviceReadIOps": null, "BlkioDeviceWriteIOps": null, "CpuPeriod": 0, "CpuQuota": 0, "CpuRealtimePeriod": 0, "CpuRealtimeRuntime": 0, "CpusetCpus": "", "CpusetMems": "", "Devices": [], "DeviceCgroupRules": null, "DiskQuota": 0, "KernelMemory": 0, "MemoryReservation": 0, "MemorySwap": 0, "MemorySwappiness": null, "OomKillDisable": false, "PidsLimit": 0, "Ulimits": null, "CpuCount": 0, "CpuPercent": 0, "IOMaximumIOps": 0, "IOMaximumBandwidth": 0, "MaskedPaths": [ "/proc/asound", "/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi", "/sys/firmware" ], "ReadonlyPaths": [ "/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger" ] }, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc-init/diff:/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/diff:/var/lib/docker/overlay2/8108325ee844603c9b08d2772cf6e65dccf31dd5171f265078e5ed79a0ba3c0f/diff:/var/lib/docker/overlay2/e5e0cce6bf91b829a308424d99d7e56a33be3a11414ff5cdc48e762a1342b20f/diff", "MergedDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/merged", "UpperDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/diff", "WorkDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/work" }, "Name": "overlay2" }, "Mounts": [ { "Type": "bind", "Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock", "Mode": "", "RW": true, "Propagation": "rprivate" } ], "Config": { "Hostname": "ae8964ba86c7", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": true, "AttachStderr": true, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ], "Cmd": null, "Image": "containrrr/watchtower:latest", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/watchtower" ], "OnBuild": null, "Labels": { "com.centurylinklabs.watchtower": "true" } }, "NetworkSettings": { "Bridge": "", "SandboxID": "05627d36c08ed994eebc44a2a8c9365a511756b55c500fb03fd5a14477cd4bf3", "HairpinMode": false, "LinkLocalIPv6Address": "", "LinkLocalIPv6PrefixLen": 0, "Ports": {}, "SandboxKey": "/var/run/docker/netns/05627d36c08e", "SecondaryIPAddresses": null, "SecondaryIPv6Addresses": null, "EndpointID": "", "Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "MacAddress": "", "Networks": { "bridge": { "IPAMConfig": null, "Links": null, "Aliases": null, "NetworkID": "8fcfd56fa9203bafa98510abb08bff66ad05bef5b6e97d158cbae3397e1e065e", "EndpointID": "", "Gateway": "", "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "MacAddress": "", "DriverOpts": null } } } } ================================================ FILE: pkg/container/mocks/data/container_running.json ================================================ { "Id": "b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008", "Created": "2019-04-04T20:28:32.5710901Z", "Path": "/portainer", "Args": [], "State": { "Status": "running", "Running": true, "Paused": false, "Restarting": false, "OOMKilled": false, "Dead": false, "Pid": 3854, "ExitCode": 0, "Error": "", "StartedAt": "2019-04-13T22:38:24.498745809Z", "FinishedAt": "2019-04-13T22:38:18.486292076Z" }, "Image": "sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd", "ResolvConfPath": "/var/lib/docker/containers/b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008/resolv.conf", "HostnamePath": "/var/lib/docker/containers/b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008/hostname", "HostsPath": "/var/lib/docker/containers/b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008/hosts", "LogPath": "/var/lib/docker/containers/b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008/b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008-json.log", "Name": "/portainer", "RestartCount": 0, "Driver": "overlay2", "Platform": "linux", "MountLabel": "", "ProcessLabel": "", "AppArmorProfile": "", "ExecIDs": null, "HostConfig": { "Binds": [ "portainer_data:/data", "/var/run/docker.sock:/var/run/docker.sock" ], "ContainerIDFile": "", "LogConfig": { "Type": "json-file", "Config": {} }, "NetworkMode": "default", "PortBindings": { "9000/tcp": [ { "HostIp": "", "HostPort": "9000" } ] }, "RestartPolicy": { "Name": "always", "MaximumRetryCount": 0 }, "AutoRemove": false, "VolumeDriver": "", "VolumesFrom": null, "CapAdd": null, "CapDrop": null, "Dns": [], "DnsOptions": [], "DnsSearch": [], "ExtraHosts": null, "GroupAdd": null, "IpcMode": "shareable", "Cgroup": "", "Links": null, "OomScoreAdj": 0, "PidMode": "", "Privileged": false, "PublishAllPorts": false, "ReadonlyRootfs": false, "SecurityOpt": null, "UTSMode": "", "UsernsMode": "", "ShmSize": 67108864, "Runtime": "runc", "ConsoleSize": [ 0, 0 ], "Isolation": "", "CpuShares": 0, "Memory": 0, "NanoCpus": 0, "CgroupParent": "", "BlkioWeight": 0, "BlkioWeightDevice": [], "BlkioDeviceReadBps": null, "BlkioDeviceWriteBps": null, "BlkioDeviceReadIOps": null, "BlkioDeviceWriteIOps": null, "CpuPeriod": 0, "CpuQuota": 0, "CpuRealtimePeriod": 0, "CpuRealtimeRuntime": 0, "CpusetCpus": "", "CpusetMems": "", "Devices": [], "DeviceCgroupRules": null, "DiskQuota": 0, "KernelMemory": 0, "MemoryReservation": 0, "MemorySwap": 0, "MemorySwappiness": null, "OomKillDisable": false, "PidsLimit": 0, "Ulimits": null, "CpuCount": 0, "CpuPercent": 0, "IOMaximumIOps": 0, "IOMaximumBandwidth": 0, "MaskedPaths": [ "/proc/asound", "/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi", "/sys/firmware" ], "ReadonlyPaths": [ "/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger" ] }, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/99dedacb757cd8c70ccacbc4b57dd85cb34b1b6fcfd2fd1176332ce5dfa1d38c-init/diff:/var/lib/docker/overlay2/2e0c03c2476f5b4df855cb8b02a88f76d336d7e0becc3e5193906aaa760687fd/diff:/var/lib/docker/overlay2/6c3f44131f6f13c9ea1a99a1b24bf348f70ba3eef244f29202faef3a2216ac11/diff", "MergedDir": "/var/lib/docker/overlay2/99dedacb757cd8c70ccacbc4b57dd85cb34b1b6fcfd2fd1176332ce5dfa1d38c/merged", "UpperDir": "/var/lib/docker/overlay2/99dedacb757cd8c70ccacbc4b57dd85cb34b1b6fcfd2fd1176332ce5dfa1d38c/diff", "WorkDir": "/var/lib/docker/overlay2/99dedacb757cd8c70ccacbc4b57dd85cb34b1b6fcfd2fd1176332ce5dfa1d38c/work" }, "Name": "overlay2" }, "Mounts": [ { "Type": "volume", "Name": "portainer_data", "Source": "/var/lib/docker/volumes/portainer_data/_data", "Destination": "/data", "Driver": "local", "Mode": "z", "RW": true, "Propagation": "" }, { "Type": "bind", "Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock", "Mode": "", "RW": true, "Propagation": "rprivate" } ], "Config": { "Hostname": "822f0f2efd78", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": false, "AttachStderr": false, "ExposedPorts": { "9000/tcp": {} }, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ], "Cmd": null, "Image": "portainer/portainer:latest", "Volumes": { "/data": {} }, "WorkingDir": "/", "Entrypoint": [ "/portainer" ], "OnBuild": null, "Labels": {} }, "NetworkSettings": { "Bridge": "", "SandboxID": "8819e19588be798020f2d09e36a577c39a47809e68c2769a1525880c0bcd5b11", "HairpinMode": false, "LinkLocalIPv6Address": "", "LinkLocalIPv6PrefixLen": 0, "Ports": { "9000/tcp": [ { "HostIp": "0.0.0.0", "HostPort": "9000" } ] }, "SandboxKey": "/var/run/docker/netns/8819e19588be", "SecondaryIPAddresses": null, "SecondaryIPv6Addresses": null, "EndpointID": "a8bcd737f27edb4d2955f7bce0c777bb2990b792a6b335b0727387624abe0702", "Gateway": "172.17.0.1", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "IPAddress": "172.17.0.2", "IPPrefixLen": 16, "IPv6Gateway": "", "MacAddress": "02:42:ac:11:00:02", "Networks": { "bridge": { "IPAMConfig": null, "Links": null, "Aliases": null, "NetworkID": "9352796e0330dcf31ce3d44fae4b719304b8b3fd97b02ade3aefb8737251682b", "EndpointID": "a8bcd737f27edb4d2955f7bce0c777bb2990b792a6b335b0727387624abe0702", "Gateway": "172.17.0.1", "IPAddress": "172.17.0.2", "IPPrefixLen": 16, "IPv6Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "MacAddress": "02:42:ac:11:00:02", "DriverOpts": null } } } } ================================================ FILE: pkg/container/mocks/data/container_stopped.json ================================================ { "Id": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65", "Created": "2019-04-10T19:51:22.245041005Z", "Path": "/watchtower", "Args": [], "State": { "Status": "exited", "Running": false, "Paused": false, "Restarting": false, "OOMKilled": false, "Dead": false, "Pid": 0, "ExitCode": 1, "Error": "", "StartedAt": "2019-04-10T19:51:22.918972606Z", "FinishedAt": "2019-04-10T19:52:14.265091583Z" }, "Image": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", "ResolvConfPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/resolv.conf", "HostnamePath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hostname", "HostsPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hosts", "LogPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65-json.log", "Name": "/watchtower-stopped", "RestartCount": 0, "Driver": "overlay2", "Platform": "linux", "MountLabel": "", "ProcessLabel": "", "AppArmorProfile": "", "ExecIDs": null, "HostConfig": { "Binds": [ "/var/run/docker.sock:/var/run/docker.sock" ], "ContainerIDFile": "", "LogConfig": { "Type": "json-file", "Config": {} }, "NetworkMode": "default", "PortBindings": {}, "RestartPolicy": { "Name": "no", "MaximumRetryCount": 0 }, "AutoRemove": false, "VolumeDriver": "", "VolumesFrom": null, "CapAdd": null, "CapDrop": null, "Dns": [], "DnsOptions": [], "DnsSearch": [], "ExtraHosts": null, "GroupAdd": null, "IpcMode": "shareable", "Cgroup": "", "Links": null, "OomScoreAdj": 0, "PidMode": "", "Privileged": false, "PublishAllPorts": false, "ReadonlyRootfs": false, "SecurityOpt": null, "UTSMode": "", "UsernsMode": "", "ShmSize": 67108864, "Runtime": "runc", "ConsoleSize": [ 0, 0 ], "Isolation": "", "CpuShares": 0, "Memory": 0, "NanoCpus": 0, "CgroupParent": "", "BlkioWeight": 0, "BlkioWeightDevice": [], "BlkioDeviceReadBps": null, "BlkioDeviceWriteBps": null, "BlkioDeviceReadIOps": null, "BlkioDeviceWriteIOps": null, "CpuPeriod": 0, "CpuQuota": 0, "CpuRealtimePeriod": 0, "CpuRealtimeRuntime": 0, "CpusetCpus": "", "CpusetMems": "", "Devices": [], "DeviceCgroupRules": null, "DiskQuota": 0, "KernelMemory": 0, "MemoryReservation": 0, "MemorySwap": 0, "MemorySwappiness": null, "OomKillDisable": false, "PidsLimit": 0, "Ulimits": null, "CpuCount": 0, "CpuPercent": 0, "IOMaximumIOps": 0, "IOMaximumBandwidth": 0, "MaskedPaths": [ "/proc/asound", "/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi", "/sys/firmware" ], "ReadonlyPaths": [ "/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger" ] }, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc-init/diff:/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/diff:/var/lib/docker/overlay2/8108325ee844603c9b08d2772cf6e65dccf31dd5171f265078e5ed79a0ba3c0f/diff:/var/lib/docker/overlay2/e5e0cce6bf91b829a308424d99d7e56a33be3a11414ff5cdc48e762a1342b20f/diff", "MergedDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/merged", "UpperDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/diff", "WorkDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/work" }, "Name": "overlay2" }, "Mounts": [ { "Type": "bind", "Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock", "Mode": "", "RW": true, "Propagation": "rprivate" } ], "Config": { "Hostname": "ae8964ba86c7", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": true, "AttachStderr": true, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ], "Cmd": null, "Image": "containrrr/watchtower:latest", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/watchtower" ], "OnBuild": null, "Labels": { "com.centurylinklabs.watchtower": "true" } }, "NetworkSettings": { "Bridge": "", "SandboxID": "05627d36c08ed994eebc44a2a8c9365a511756b55c500fb03fd5a14477cd4bf3", "HairpinMode": false, "LinkLocalIPv6Address": "", "LinkLocalIPv6PrefixLen": 0, "Ports": {}, "SandboxKey": "/var/run/docker/netns/05627d36c08e", "SecondaryIPAddresses": null, "SecondaryIPv6Addresses": null, "EndpointID": "", "Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "MacAddress": "", "Networks": { "bridge": { "IPAMConfig": null, "Links": null, "Aliases": null, "NetworkID": "8fcfd56fa9203bafa98510abb08bff66ad05bef5b6e97d158cbae3397e1e065e", "EndpointID": "", "Gateway": "", "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "MacAddress": "", "DriverOpts": null } } } } ================================================ FILE: pkg/container/mocks/data/container_watchtower.json ================================================ { "Id": "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134", "Created": "2020-04-10T19:51:22.245041005Z", "Path": "/watchtower", "Args": [], "State": { "Status": "running", "Running": true, "Paused": false, "Restarting": false, "OOMKilled": false, "Dead": false, "Pid": 3854, "ExitCode": 0, "Error": "", "StartedAt": "2019-04-13T22:38:24.498745809Z", "FinishedAt": "2019-04-13T22:38:18.486292076Z" }, "Image": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", "ResolvConfPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/resolv.conf", "HostnamePath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hostname", "HostsPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hosts", "LogPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65-json.log", "Name": "/watchtower-running", "RestartCount": 0, "Driver": "overlay2", "Platform": "linux", "MountLabel": "", "ProcessLabel": "", "AppArmorProfile": "", "ExecIDs": null, "HostConfig": { "Binds": [ "/var/run/docker.sock:/var/run/docker.sock" ], "ContainerIDFile": "", "LogConfig": { "Type": "json-file", "Config": {} }, "NetworkMode": "default", "PortBindings": {}, "RestartPolicy": { "Name": "no", "MaximumRetryCount": 0 }, "AutoRemove": false, "VolumeDriver": "", "VolumesFrom": null, "CapAdd": null, "CapDrop": null, "Dns": [], "DnsOptions": [], "DnsSearch": [], "ExtraHosts": null, "GroupAdd": null, "IpcMode": "shareable", "Cgroup": "", "Links": null, "OomScoreAdj": 0, "PidMode": "", "Privileged": false, "PublishAllPorts": false, "ReadonlyRootfs": false, "SecurityOpt": null, "UTSMode": "", "UsernsMode": "", "ShmSize": 67108864, "Runtime": "runc", "ConsoleSize": [ 0, 0 ], "Isolation": "", "CpuShares": 0, "Memory": 0, "NanoCpus": 0, "CgroupParent": "", "BlkioWeight": 0, "BlkioWeightDevice": [], "BlkioDeviceReadBps": null, "BlkioDeviceWriteBps": null, "BlkioDeviceReadIOps": null, "BlkioDeviceWriteIOps": null, "CpuPeriod": 0, "CpuQuota": 0, "CpuRealtimePeriod": 0, "CpuRealtimeRuntime": 0, "CpusetCpus": "", "CpusetMems": "", "Devices": [], "DeviceCgroupRules": null, "DiskQuota": 0, "KernelMemory": 0, "MemoryReservation": 0, "MemorySwap": 0, "MemorySwappiness": null, "OomKillDisable": false, "PidsLimit": 0, "Ulimits": null, "CpuCount": 0, "CpuPercent": 0, "IOMaximumIOps": 0, "IOMaximumBandwidth": 0, "MaskedPaths": [ "/proc/asound", "/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi", "/sys/firmware" ], "ReadonlyPaths": [ "/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger" ] }, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc-init/diff:/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/diff:/var/lib/docker/overlay2/8108325ee844603c9b08d2772cf6e65dccf31dd5171f265078e5ed79a0ba3c0f/diff:/var/lib/docker/overlay2/e5e0cce6bf91b829a308424d99d7e56a33be3a11414ff5cdc48e762a1342b20f/diff", "MergedDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/merged", "UpperDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/diff", "WorkDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/work" }, "Name": "overlay2" }, "Mounts": [ { "Type": "bind", "Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock", "Mode": "", "RW": true, "Propagation": "rprivate" } ], "Config": { "Hostname": "ae8964ba86c7", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": true, "AttachStderr": true, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ], "Cmd": null, "Image": "containrrr/watchtower:latest", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/watchtower" ], "OnBuild": null, "Labels": { "com.centurylinklabs.watchtower": "true" } }, "NetworkSettings": { "Bridge": "", "SandboxID": "05627d36c08ed994eebc44a2a8c9365a511756b55c500fb03fd5a14477cd4bf3", "HairpinMode": false, "LinkLocalIPv6Address": "", "LinkLocalIPv6PrefixLen": 0, "Ports": {}, "SandboxKey": "/var/run/docker/netns/05627d36c08e", "SecondaryIPAddresses": null, "SecondaryIPv6Addresses": null, "EndpointID": "", "Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "MacAddress": "", "Networks": { "bridge": { "IPAMConfig": null, "Links": null, "Aliases": null, "NetworkID": "8fcfd56fa9203bafa98510abb08bff66ad05bef5b6e97d158cbae3397e1e065e", "EndpointID": "", "Gateway": "", "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "MacAddress": "", "DriverOpts": null } } } } ================================================ FILE: pkg/container/mocks/data/containers.json ================================================ [ { "Id": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65", "Names": [ "/watchtower-stopped" ], "Image": "containrrr/watchtower:latest", "ImageID": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", "Command": "/watchtower", "Created": 1554925882, "Ports": [], "Labels": { "com.centurylinklabs.watchtower": "true" }, "State": "exited", "Status": "Exited (1) 6 days ago", "HostConfig": { "NetworkMode": "default" }, "NetworkSettings": { "Networks": { "bridge": { "IPAMConfig": null, "Links": null, "Aliases": null, "NetworkID": "8fcfd56fa9203bafa98510abb08bff66ad05bef5b6e97d158cbae3397e1e065e", "EndpointID": "", "Gateway": "", "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "MacAddress": "", "DriverOpts": null } } }, "Mounts": [ { "Type": "bind", "Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock", "Mode": "", "RW": true, "Propagation": "rprivate" } ] }, { "Id": "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134", "Names": [ "/watchtower-running" ], "Image": "containrrr/watchtower:latest", "ImageID": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", "Command": "/watchtower", "Created": 1554925882, "Ports": [], "Labels": { "com.centurylinklabs.watchtower": "true" }, "State": "running", "Status": "Up 3 days", "HostConfig": { "NetworkMode": "default" }, "NetworkSettings": { "Networks": { "bridge": { "IPAMConfig": null, "Links": null, "Aliases": null, "NetworkID": "8fcfd56fa9203bafa98510abb08bff66ad05bef5b6e97d158cbae3397e1e065e", "EndpointID": "", "Gateway": "", "IPAddress": "", "IPPrefixLen": 0, "IPv6Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "MacAddress": "", "DriverOpts": null } } }, "Mounts": [ { "Type": "bind", "Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock", "Mode": "", "RW": true, "Propagation": "rprivate" } ] }, { "Id": "b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008", "Names": [ "/portainer" ], "Image": "portainer/portainer:latest", "ImageID": "sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd", "Command": "/portainer", "Created": 1554409712, "Ports": [ { "IP": "0.0.0.0", "PrivatePort": 9000, "PublicPort": 9000, "Type": "tcp" } ], "Labels": {}, "State": "running", "Status": "Up 3 days", "HostConfig": { "NetworkMode": "default" }, "NetworkSettings": { "Networks": { "bridge": { "IPAMConfig": null, "Links": null, "Aliases": null, "NetworkID": "9352796e0330dcf31ce3d44fae4b719304b8b3fd97b02ade3aefb8737251682b", "EndpointID": "a8bcd737f27edb4d2955f7bce0c777bb2990b792a6b335b0727387624abe0702", "Gateway": "172.17.0.1", "IPAddress": "172.17.0.2", "IPPrefixLen": 16, "IPv6Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "MacAddress": "02:42:ac:11:00:02", "DriverOpts": null } } }, "Mounts": [ { "Type": "volume", "Name": "portainer_data", "Source": "/var/lib/docker/volumes/portainer_data/_data", "Destination": "/data", "Driver": "local", "Mode": "z", "RW": true, "Propagation": "" }, { "Type": "bind", "Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock", "Mode": "", "RW": true, "Propagation": "rprivate" } ] }, { "Id": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67", "Names": [ "/portainer" ], "Image": "portainer/portainer:latest", "ImageID": "sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd", "Command": "/portainer", "Created": 1554409712, "Ports": [ { "IP": "0.0.0.0", "PrivatePort": 9000, "PublicPort": 9000, "Type": "tcp" } ], "Labels": {}, "State": "restarting", "Status": "Restarting (0) 35 seconds ago", "HostConfig": { "NetworkMode": "default" }, "NetworkSettings": { "Networks": { "bridge": { "IPAMConfig": null, "Links": null, "Aliases": null, "NetworkID": "9352796e0330dcf31ce3d44fae4b719304b8b3fd97b02ade3aefb8737251682b", "EndpointID": "a8bcd737f27edb4d2955f7bce0c777bb2990b792a6b335b0727387624abe0702", "Gateway": "172.17.0.1", "IPAddress": "172.17.0.2", "IPPrefixLen": 16, "IPv6Gateway": "", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "MacAddress": "02:42:ac:11:00:02", "DriverOpts": null } } }, "Mounts": [ { "Type": "volume", "Name": "portainer_data", "Source": "/var/lib/docker/volumes/portainer_data/_data", "Destination": "/data", "Driver": "local", "Mode": "z", "RW": true, "Propagation": "" }, { "Type": "bind", "Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock", "Mode": "", "RW": true, "Propagation": "rprivate" } ] } ] ================================================ FILE: pkg/container/mocks/data/image_default.json ================================================ { "Id": "sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd", "RepoTags": [ "portainer/portainer:latest" ], "RepoDigests": [ "portainer/portainer@sha256:d6cc2c20c0af38d8d557ab994c419c799a10fe825e4aa57fea2e2e507a13747d" ], "Parent": "", "Comment": "", "Created": "2019-03-05T04:41:17.612066939Z", "Container": "022100cf79dfee27867d5ff7aa3ff7ecc5cbd486747e808a59b6accd393d65f5", "ContainerConfig": { "Hostname": "022100cf79df", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": false, "AttachStderr": false, "ExposedPorts": { "9000/tcp": {} }, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ], "Cmd": [ "/bin/sh", "-c", "#(nop) ", "ENTRYPOINT [\"/portainer\"]" ], "Image": "sha256:9cf3ead5068a16f1bc1e18d6e730940f05fd59f60dfe1f6b3a5956196191dc77", "Volumes": { "/data": {} }, "WorkingDir": "/", "Entrypoint": [ "/portainer" ], "OnBuild": null, "Labels": {} }, "DockerVersion": "18.09.2", "Author": "", "Config": { "Hostname": "", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": false, "AttachStderr": false, "ExposedPorts": { "9000/tcp": {} }, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ], "Cmd": null, "Image": "sha256:9cf3ead5068a16f1bc1e18d6e730940f05fd59f60dfe1f6b3a5956196191dc77", "Volumes": { "/data": {} }, "WorkingDir": "/", "Entrypoint": [ "/portainer" ], "OnBuild": null, "Labels": null }, "Architecture": "amd64", "Os": "linux", "Size": 74089106, "VirtualSize": 74089106, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/6c3f44131f6f13c9ea1a99a1b24bf348f70ba3eef244f29202faef3a2216ac11/diff", "MergedDir": "/var/lib/docker/overlay2/2e0c03c2476f5b4df855cb8b02a88f76d336d7e0becc3e5193906aaa760687fd/merged", "UpperDir": "/var/lib/docker/overlay2/2e0c03c2476f5b4df855cb8b02a88f76d336d7e0becc3e5193906aaa760687fd/diff", "WorkDir": "/var/lib/docker/overlay2/2e0c03c2476f5b4df855cb8b02a88f76d336d7e0becc3e5193906aaa760687fd/work" }, "Name": "overlay2" }, "RootFS": { "Type": "layers", "Layers": [ "sha256:dd4969f97241b9aefe2a70f560ce399ee9fa0354301c9aef841082ad52161ec5", "sha256:e7260fd2a5f240122129b2d421726d7a4a2bda0cc292e962b694196af8856f20" ] }, "Metadata": { "LastTagTime": "0001-01-01T00:00:00Z" } } ================================================ FILE: pkg/container/mocks/data/image_net_consumer.json ================================================ { "Id": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8", "RepoTags": [ "nginx:latest" ], "RepoDigests": [ "nginx@sha256:aa0afebbb3cfa473099a62c4b32e9b3fb73ed23f2a75a65ce1d4b4f55a5c2ef2" ], "Parent": "", "Comment": "", "Created": "2023-03-01T18:43:12.914398123Z", "Container": "71a4c9a59d252d7c54812429bfe5df477e54e91ebfff1939ae39ecdf055d445c", "ContainerConfig": { "Hostname": "71a4c9a59d25", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": false, "AttachStderr": false, "ExposedPorts": { "80/tcp": {} }, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "NGINX_VERSION=1.23.3", "NJS_VERSION=0.7.9", "PKG_RELEASE=1~bullseye" ], "Cmd": [ "/bin/sh", "-c", "#(nop) ", "CMD [\"nginx\" \"-g\" \"daemon off;\"]" ], "Image": "sha256:6716b8a33f73b21e193bb63424ea1105eaaa6a8237fefe75570bea18c87a1711", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/docker-entrypoint.sh" ], "OnBuild": null, "Labels": { "maintainer": "NGINX Docker Maintainers " }, "StopSignal": "SIGQUIT" }, "DockerVersion": "20.10.23", "Author": "", "Config": { "Hostname": "", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": false, "AttachStderr": false, "ExposedPorts": { "80/tcp": {} }, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "NGINX_VERSION=1.23.3", "NJS_VERSION=0.7.9", "PKG_RELEASE=1~bullseye" ], "Cmd": [ "nginx", "-g", "daemon off;" ], "Image": "sha256:6716b8a33f73b21e193bb63424ea1105eaaa6a8237fefe75570bea18c87a1711", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/docker-entrypoint.sh" ], "OnBuild": null, "Labels": { "maintainer": "NGINX Docker Maintainers " }, "StopSignal": "SIGQUIT" }, "Architecture": "amd64", "Os": "linux", "Size": 141838643, "VirtualSize": 141838643, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/09785ba17f27c783ef8b44f369f9aac0ca936000b57abf22b3c54d1e6eb8e27b/diff:/var/lib/docker/overlay2/6f8acd64ae44fd4d14bcb90c105eceba46854aa3985b5b6b317bcc5692cfc286/diff:/var/lib/docker/overlay2/73d41c15edb21c5f12cf53e313f48b5da55283aafc77d35b7bc662241879d7e7/diff:/var/lib/docker/overlay2/d97b55f3d966ae031492369a98e9e00d2bd31e520290fe2034e0a2b1ed77c91e/diff:/var/lib/docker/overlay2/053e9ca65c6b64cb9d98a812ff7488c7e77938b4fb8e0c4d2ad7f8ec235f0f20/diff", "MergedDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/merged", "UpperDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/diff", "WorkDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/work" }, "Name": "overlay2" }, "RootFS": { "Type": "layers", "Layers": [ "sha256:650abce4b096b06ac8bec2046d821d66d801af34f1f1d4c5e272ad030c7873db", "sha256:4dc5cd799a08ff49a603870c8378ea93083bfc2a4176f56e5531997e94c195d0", "sha256:e161c82b34d21179db1f546c1cd84153d28a17d865ccaf2dedeb06a903fec12c", "sha256:83ba6d8ffb8c2974174c02d3ba549e7e0656ebb1bc075a6b6ee89b6c609c6a71", "sha256:d8466e142d8710abf5b495ebb536478f7e19d9d03b151b5d5bd09df4cfb49248", "sha256:101af4ba983b04be266217ecee414e88b23e394f62e9801c7c1bdb37cb37bcaa" ] }, "Metadata": { "LastTagTime": "0001-01-01T00:00:00Z" } } ================================================ FILE: pkg/container/mocks/data/image_net_producer.json ================================================ { "Id": "sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51", "RepoTags": [ "qmcgaw/gluetun:latest" ], "RepoDigests": [ "qmcgaw/gluetun@sha256:cd532bf4ef88a348a915c6dc62a9867a2eca89aa70559b0b4a1ea15cc0e595d1" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", "Created": "2023-07-22T16:10:29.457146856Z", "Container": "", "ContainerConfig": { "Hostname": "", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": false, "AttachStderr": false, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": null, "Cmd": null, "Image": "", "Volumes": null, "WorkingDir": "", "Entrypoint": null, "OnBuild": null, "Labels": null }, "DockerVersion": "", "Author": "", "Config": { "Hostname": "", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": false, "AttachStderr": false, "ExposedPorts": { "8000/tcp": {}, "8388/tcp": {}, "8388/udp": {}, "8888/tcp": {} }, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "VPN_SERVICE_PROVIDER=pia", "VPN_TYPE=openvpn", "VPN_ENDPOINT_IP=", "VPN_ENDPOINT_PORT=", "VPN_INTERFACE=tun0", "OPENVPN_PROTOCOL=udp", "OPENVPN_USER=", "OPENVPN_PASSWORD=", "OPENVPN_USER_SECRETFILE=/run/secrets/openvpn_user", "OPENVPN_PASSWORD_SECRETFILE=/run/secrets/openvpn_password", "OPENVPN_VERSION=2.5", "OPENVPN_VERBOSITY=1", "OPENVPN_FLAGS=", "OPENVPN_CIPHERS=", "OPENVPN_AUTH=", "OPENVPN_PROCESS_USER=root", "OPENVPN_CUSTOM_CONFIG=", "WIREGUARD_PRIVATE_KEY=", "WIREGUARD_PRESHARED_KEY=", "WIREGUARD_PUBLIC_KEY=", "WIREGUARD_ALLOWED_IPS=", "WIREGUARD_ADDRESSES=", "WIREGUARD_MTU=1400", "WIREGUARD_IMPLEMENTATION=auto", "SERVER_REGIONS=", "SERVER_COUNTRIES=", "SERVER_CITIES=", "SERVER_HOSTNAMES=", "ISP=", "OWNED_ONLY=no", "PRIVATE_INTERNET_ACCESS_OPENVPN_ENCRYPTION_PRESET=", "VPN_PORT_FORWARDING=off", "VPN_PORT_FORWARDING_PROVIDER=", "VPN_PORT_FORWARDING_STATUS_FILE=/tmp/gluetun/forwarded_port", "OPENVPN_CERT=", "OPENVPN_KEY=", "OPENVPN_CLIENTCRT_SECRETFILE=/run/secrets/openvpn_clientcrt", "OPENVPN_CLIENTKEY_SECRETFILE=/run/secrets/openvpn_clientkey", "OPENVPN_ENCRYPTED_KEY=", "OPENVPN_ENCRYPTED_KEY_SECRETFILE=/run/secrets/openvpn_encrypted_key", "OPENVPN_KEY_PASSPHRASE=", "OPENVPN_KEY_PASSPHRASE_SECRETFILE=/run/secrets/openvpn_key_passphrase", "SERVER_NUMBER=", "SERVER_NAMES=", "FREE_ONLY=", "MULTIHOP_ONLY=", "PREMIUM_ONLY=", "FIREWALL=on", "FIREWALL_VPN_INPUT_PORTS=", "FIREWALL_INPUT_PORTS=", "FIREWALL_OUTBOUND_SUBNETS=", "FIREWALL_DEBUG=off", "LOG_LEVEL=info", "HEALTH_SERVER_ADDRESS=127.0.0.1:9999", "HEALTH_TARGET_ADDRESS=cloudflare.com:443", "HEALTH_SUCCESS_WAIT_DURATION=5s", "HEALTH_VPN_DURATION_INITIAL=6s", "HEALTH_VPN_DURATION_ADDITION=5s", "DOT=on", "DOT_PROVIDERS=cloudflare", "DOT_PRIVATE_ADDRESS=127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1/128,fc00::/7,fe80::/10,::ffff:7f00:1/104,::ffff:a00:0/104,::ffff:a9fe:0/112,::ffff:ac10:0/108,::ffff:c0a8:0/112", "DOT_VERBOSITY=1", "DOT_VERBOSITY_DETAILS=0", "DOT_VALIDATION_LOGLEVEL=0", "DOT_CACHING=on", "DOT_IPV6=off", "BLOCK_MALICIOUS=on", "BLOCK_SURVEILLANCE=off", "BLOCK_ADS=off", "UNBLOCK=", "DNS_UPDATE_PERIOD=24h", "DNS_ADDRESS=127.0.0.1", "DNS_KEEP_NAMESERVER=off", "HTTPPROXY=", "HTTPPROXY_LOG=off", "HTTPPROXY_LISTENING_ADDRESS=:8888", "HTTPPROXY_STEALTH=off", "HTTPPROXY_USER=", "HTTPPROXY_PASSWORD=", "HTTPPROXY_USER_SECRETFILE=/run/secrets/httpproxy_user", "HTTPPROXY_PASSWORD_SECRETFILE=/run/secrets/httpproxy_password", "SHADOWSOCKS=off", "SHADOWSOCKS_LOG=off", "SHADOWSOCKS_LISTENING_ADDRESS=:8388", "SHADOWSOCKS_PASSWORD=", "SHADOWSOCKS_PASSWORD_SECRETFILE=/run/secrets/shadowsocks_password", "SHADOWSOCKS_CIPHER=chacha20-ietf-poly1305", "HTTP_CONTROL_SERVER_LOG=on", "HTTP_CONTROL_SERVER_ADDRESS=:8000", "UPDATER_PERIOD=0", "UPDATER_MIN_RATIO=0.8", "UPDATER_VPN_SERVICE_PROVIDERS=", "PUBLICIP_FILE=/tmp/gluetun/ip", "PUBLICIP_PERIOD=12h", "PPROF_ENABLED=no", "PPROF_BLOCK_PROFILE_RATE=0", "PPROF_MUTEX_PROFILE_RATE=0", "PPROF_HTTP_SERVER_ADDRESS=:6060", "VERSION_INFORMATION=on", "TZ=", "PUID=", "PGID=" ], "Cmd": null, "Healthcheck": { "Test": [ "CMD-SHELL", "/gluetun-entrypoint healthcheck" ], "Interval": 5000000000, "Timeout": 5000000000, "StartPeriod": 10000000000, "Retries": 1 }, "Image": "", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/gluetun-entrypoint" ], "OnBuild": null, "Labels": { "org.opencontainers.image.authors": "quentin.mcgaw@gmail.com", "org.opencontainers.image.created": "2023-07-22T16:07:05.641Z", "org.opencontainers.image.description": "VPN client in a thin Docker container for multiple VPN providers, written in Go, and using OpenVPN or Wireguard, DNS over TLS, with a few proxy servers built-in.", "org.opencontainers.image.documentation": "https://github.com/qdm12/gluetun", "org.opencontainers.image.licenses": "MIT", "org.opencontainers.image.revision": "eecfb3952f202c0de3867d88e96d80c6b0f48359", "org.opencontainers.image.source": "https://github.com/qdm12/gluetun", "org.opencontainers.image.title": "gluetun", "org.opencontainers.image.url": "https://github.com/qdm12/gluetun", "org.opencontainers.image.version": "latest" } }, "Architecture": "amd64", "Os": "linux", "Size": 42602255, "VirtualSize": 42602255, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/a20c9490a23ee8af51898892d9bf32258d44e0e07f3799475be8e8f273a50f73/diff:/var/lib/docker/overlay2/d4c97f367c37c6ada9de57f438a3e19cc714be2a54a6f582a03de9e42d88b344/diff", "MergedDir": "/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/merged", "UpperDir": "/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/diff", "WorkDir": "/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/work" }, "Name": "overlay2" }, "RootFS": { "Type": "layers", "Layers": [ "sha256:78a822fe2a2d2c84f3de4a403188c45f623017d6a4521d23047c9fbb0801794c", "sha256:122dbeefc08382d88b3fe57ad81c1e2428af5b81c172d112723a33e2a20fe880", "sha256:3d215e55b88a99dcd7cf4349618326ab129771e12fdf6c6ef5cbb71a265dbb6c" ] }, "Metadata": { "LastTagTime": "0001-01-01T00:00:00Z" } } ================================================ FILE: pkg/container/mocks/data/image_running.json ================================================ { "Id": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", "RepoTags": [ "containrrr/watchtower:latest" ], "RepoDigests": [], "Parent": "sha256:2753b9621e0d76153e1725d0cea015baf0ae4d829782a463b4ea9532ec976447", "Comment": "", "Created": "2019-04-10T19:49:07.970840451Z", "Container": "b8387976426946f5c5191255204a66514c5e64be157f792c5bac329bb055041c", "ContainerConfig": { "Hostname": "b83879764269", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": false, "AttachStderr": false, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ], "Cmd": [ "/bin/sh", "-c", "#(nop) ", "ENTRYPOINT [\"/watchtower\"]" ], "Image": "sha256:2753b9621e0d76153e1725d0cea015baf0ae4d829782a463b4ea9532ec976447", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/watchtower" ], "OnBuild": null, "Labels": { "com.centurylinklabs.watchtower": "true" } }, "DockerVersion": "18.09.1", "Author": "", "Config": { "Hostname": "", "Domainname": "", "User": "", "AttachStdin": false, "AttachStdout": false, "AttachStderr": false, "Tty": false, "OpenStdin": false, "StdinOnce": false, "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ], "Cmd": null, "Image": "sha256:2753b9621e0d76153e1725d0cea015baf0ae4d829782a463b4ea9532ec976447", "Volumes": null, "WorkingDir": "", "Entrypoint": [ "/watchtower" ], "OnBuild": null, "Labels": { "com.centurylinklabs.watchtower": "true" } }, "Architecture": "amd64", "Os": "linux", "Size": 13005733, "VirtualSize": 13005733, "GraphDriver": { "Data": { "LowerDir": "/var/lib/docker/overlay2/8108325ee844603c9b08d2772cf6e65dccf31dd5171f265078e5ed79a0ba3c0f/diff:/var/lib/docker/overlay2/e5e0cce6bf91b829a308424d99d7e56a33be3a11414ff5cdc48e762a1342b20f/diff", "MergedDir": "/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/merged", "UpperDir": "/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/diff", "WorkDir": "/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/work" }, "Name": "overlay2" }, "RootFS": { "Type": "layers", "Layers": [ "sha256:1d3ad125af2c636cdd793fcf94c9d4fd2b5c4c7d63a770a01056719db13c2271", "sha256:06cfe8fe0892ba4a91cb93e3a25344d4a1c4771cf7297a93e3bd86a1e0fba6eb", "sha256:f58d451769dc30a938d8dcae22fda2acd816899f65fc6b6fa519ddf230dab447" ] }, "Metadata": { "LastTagTime": "2019-04-10T19:49:08.03921105Z" } } ================================================ FILE: pkg/container/util_test.go ================================================ package container_test import ( wt "github.com/containrrr/watchtower/pkg/types" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) var _ = Describe("container utils", func() { Describe("ShortID", func() { When("given a normal image ID", func() { When("it contains a sha256 prefix", func() { It("should return that ID in short version", func() { actual := shortID("sha256:0123456789abcd00000000001111111111222222222233333333334444444444") Expect(actual).To(Equal("0123456789ab")) }) }) When("it doesn't contain a prefix", func() { It("should return that ID in short version", func() { actual := shortID("0123456789abcd00000000001111111111222222222233333333334444444444") Expect(actual).To(Equal("0123456789ab")) }) }) }) When("given a short image ID", func() { When("it contains no prefix", func() { It("should return the same string", func() { Expect(shortID("0123456789ab")).To(Equal("0123456789ab")) }) }) When("it contains a the sha256 prefix", func() { It("should return the ID without the prefix", func() { Expect(shortID("sha256:0123456789ab")).To(Equal("0123456789ab")) }) }) }) When("given an ID with an unknown prefix", func() { It("should return a short version of that ID including the prefix", func() { Expect(shortID("md5:0123456789ab")).To(Equal("md5:0123456789ab")) Expect(shortID("md5:0123456789abcdefg")).To(Equal("md5:0123456789ab")) Expect(shortID("md5:01")).To(Equal("md5:01")) }) }) }) }) func shortID(id string) string { // Proxy to the types implementation, relocated due to package dependency resolution return wt.ImageID(id).ShortID() } ================================================ FILE: pkg/filters/filters.go ================================================ package filters import ( "regexp" "strings" t "github.com/containrrr/watchtower/pkg/types" ) // WatchtowerContainersFilter filters only watchtower containers func WatchtowerContainersFilter(c t.FilterableContainer) bool { return c.IsWatchtower() } // NoFilter will not filter out any containers func NoFilter(t.FilterableContainer) bool { return true } // FilterByNames returns all containers that match one of the specified names func FilterByNames(names []string, baseFilter t.Filter) t.Filter { if len(names) == 0 { return baseFilter } return func(c t.FilterableContainer) bool { for _, name := range names { if name == c.Name() || name == c.Name()[1:] { return baseFilter(c) } if re, err := regexp.Compile(name); err == nil { indices := re.FindStringIndex(c.Name()) if indices == nil { continue } start := indices[0] end := indices[1] if start <= 1 && end >= len(c.Name())-1 { return baseFilter(c) } } } return false } } // FilterByDisableNames returns all containers that don't match any of the specified names func FilterByDisableNames(disableNames []string, baseFilter t.Filter) t.Filter { if len(disableNames) == 0 { return baseFilter } return func(c t.FilterableContainer) bool { for _, name := range disableNames { if name == c.Name() || name == c.Name()[1:] { return false } } return baseFilter(c) } } // FilterByEnableLabel returns all containers that have the enabled label set func FilterByEnableLabel(baseFilter t.Filter) t.Filter { return func(c t.FilterableContainer) bool { // If label filtering is enabled, containers should only be considered // if the label is specifically set. _, ok := c.Enabled() if !ok { return false } return baseFilter(c) } } // FilterByDisabledLabel returns all containers that have the enabled label set to disable func FilterByDisabledLabel(baseFilter t.Filter) t.Filter { return func(c t.FilterableContainer) bool { enabledLabel, ok := c.Enabled() if ok && !enabledLabel { // If the label has been set and it demands a disable return false } return baseFilter(c) } } // FilterByScope returns all containers that belongs to a specific scope func FilterByScope(scope string, baseFilter t.Filter) t.Filter { return func(c t.FilterableContainer) bool { containerScope, containerHasScope := c.Scope() if !containerHasScope || containerScope == "" { containerScope = "none" } if containerScope == scope { return baseFilter(c) } return false } } // FilterByImage returns all containers that have a specific image func FilterByImage(images []string, baseFilter t.Filter) t.Filter { if images == nil { return baseFilter } return func(c t.FilterableContainer) bool { image := strings.Split(c.ImageName(), ":")[0] for _, targetImage := range images { if image == targetImage { return baseFilter(c) } } return false } } // BuildFilter creates the needed filter of containers func BuildFilter(names []string, disableNames []string, enableLabel bool, scope string) (t.Filter, string) { sb := strings.Builder{} filter := NoFilter filter = FilterByNames(names, filter) filter = FilterByDisableNames(disableNames, filter) if len(names) > 0 { sb.WriteString("which name matches \"") for i, n := range names { sb.WriteString(n) if i < len(names)-1 { sb.WriteString(`" or "`) } } sb.WriteString(`", `) } if len(disableNames) > 0 { sb.WriteString("not named one of \"") for i, n := range disableNames { sb.WriteString(n) if i < len(disableNames)-1 { sb.WriteString(`" or "`) } } sb.WriteString(`", `) } if enableLabel { // If label filtering is enabled, containers should only be considered // if the label is specifically set. filter = FilterByEnableLabel(filter) sb.WriteString("using enable label, ") } if scope == "none" { // If a scope has explicitly defined as "none", containers should only be considered // if they do not have a scope defined, or if it's explicitly set to "none". filter = FilterByScope(scope, filter) sb.WriteString(`without a scope, "`) } else if scope != "" { // If a scope has been defined, containers should only be considered // if the scope is specifically set. filter = FilterByScope(scope, filter) sb.WriteString(`in scope "`) sb.WriteString(scope) sb.WriteString(`", `) } filter = FilterByDisabledLabel(filter) filterDesc := "Checking all containers (except explicitly disabled with label)" if sb.Len() > 0 { filterDesc = "Only checking containers " + sb.String() // Remove the last ", " filterDesc = filterDesc[:len(filterDesc)-2] } return filter, filterDesc } ================================================ FILE: pkg/filters/filters_test.go ================================================ package filters import ( "testing" "github.com/containrrr/watchtower/pkg/container/mocks" "github.com/stretchr/testify/assert" ) func TestWatchtowerContainersFilter(t *testing.T) { container := new(mocks.FilterableContainer) container.On("IsWatchtower").Return(true) assert.True(t, WatchtowerContainersFilter(container)) container.AssertExpectations(t) } func TestNoFilter(t *testing.T) { container := new(mocks.FilterableContainer) assert.True(t, NoFilter(container)) container.AssertExpectations(t) } func TestFilterByNames(t *testing.T) { var names []string filter := FilterByNames(names, nil) assert.Nil(t, filter) names = append(names, "test") filter = FilterByNames(names, NoFilter) assert.NotNil(t, filter) container := new(mocks.FilterableContainer) container.On("Name").Return("test") assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("NoTest") assert.False(t, filter(container)) container.AssertExpectations(t) } func TestFilterByNamesRegex(t *testing.T) { names := []string{`ba(b|ll)oon`} filter := FilterByNames(names, NoFilter) assert.NotNil(t, filter) container := new(mocks.FilterableContainer) container.On("Name").Return("balloon") assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("spoon") assert.False(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("baboonious") assert.False(t, filter(container)) container.AssertExpectations(t) } func TestFilterByEnableLabel(t *testing.T) { filter := FilterByEnableLabel(NoFilter) assert.NotNil(t, filter) container := new(mocks.FilterableContainer) container.On("Enabled").Return(true, true) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Enabled").Return(false, true) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Enabled").Return(false, false) assert.False(t, filter(container)) container.AssertExpectations(t) } func TestFilterByScope(t *testing.T) { scope := "testscope" filter := FilterByScope(scope, NoFilter) assert.NotNil(t, filter) container := new(mocks.FilterableContainer) container.On("Scope").Return("testscope", true) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Scope").Return("nottestscope", true) assert.False(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Scope").Return("", false) assert.False(t, filter(container)) container.AssertExpectations(t) } func TestFilterByNoneScope(t *testing.T) { scope := "none" filter := FilterByScope(scope, NoFilter) assert.NotNil(t, filter) container := new(mocks.FilterableContainer) container.On("Scope").Return("anyscope", true) assert.False(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Scope").Return("", false) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Scope").Return("", true) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Scope").Return("none", true) assert.True(t, filter(container)) container.AssertExpectations(t) } func TestBuildFilterNoneScope(t *testing.T) { filter, desc := BuildFilter(nil, nil, false, "none") assert.Contains(t, desc, "without a scope") scoped := new(mocks.FilterableContainer) scoped.On("Enabled").Return(false, false) scoped.On("Scope").Return("anyscope", true) unscoped := new(mocks.FilterableContainer) unscoped.On("Enabled").Return(false, false) unscoped.On("Scope").Return("", false) assert.False(t, filter(scoped)) assert.True(t, filter(unscoped)) scoped.AssertExpectations(t) unscoped.AssertExpectations(t) } func TestFilterByDisabledLabel(t *testing.T) { filter := FilterByDisabledLabel(NoFilter) assert.NotNil(t, filter) container := new(mocks.FilterableContainer) container.On("Enabled").Return(true, true) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Enabled").Return(false, true) assert.False(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Enabled").Return(false, false) assert.True(t, filter(container)) container.AssertExpectations(t) } func TestFilterByImage(t *testing.T) { filterEmpty := FilterByImage(nil, NoFilter) filterSingle := FilterByImage([]string{"registry"}, NoFilter) filterMultiple := FilterByImage([]string{"registry", "bla"}, NoFilter) assert.NotNil(t, filterSingle) assert.NotNil(t, filterMultiple) container := new(mocks.FilterableContainer) container.On("ImageName").Return("registry:2") assert.True(t, filterEmpty(container)) assert.True(t, filterSingle(container)) assert.True(t, filterMultiple(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("ImageName").Return("registry:latest") assert.True(t, filterEmpty(container)) assert.True(t, filterSingle(container)) assert.True(t, filterMultiple(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("ImageName").Return("abcdef1234") assert.True(t, filterEmpty(container)) assert.False(t, filterSingle(container)) assert.False(t, filterMultiple(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("ImageName").Return("bla:latest") assert.True(t, filterEmpty(container)) assert.False(t, filterSingle(container)) assert.True(t, filterMultiple(container)) container.AssertExpectations(t) } func TestBuildFilter(t *testing.T) { names := []string{"test", "valid"} filter, desc := BuildFilter(names, []string{}, false, "") assert.Contains(t, desc, "test") assert.Contains(t, desc, "or") assert.Contains(t, desc, "valid") container := new(mocks.FilterableContainer) container.On("Name").Return("Invalid") container.On("Enabled").Return(false, false) assert.False(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("test") container.On("Enabled").Return(false, false) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("Invalid") container.On("Enabled").Return(true, true) assert.False(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("test") container.On("Enabled").Return(true, true) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Enabled").Return(false, true) assert.False(t, filter(container)) container.AssertExpectations(t) } func TestBuildFilterEnableLabel(t *testing.T) { var names []string names = append(names, "test") filter, desc := BuildFilter(names, []string{}, true, "") assert.Contains(t, desc, "using enable label") container := new(mocks.FilterableContainer) container.On("Enabled").Return(false, false) assert.False(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("Invalid") container.On("Enabled").Twice().Return(true, true) assert.False(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("test") container.On("Enabled").Twice().Return(true, true) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Enabled").Return(false, true) assert.False(t, filter(container)) container.AssertExpectations(t) } func TestBuildFilterDisableContainer(t *testing.T) { filter, desc := BuildFilter([]string{}, []string{"excluded", "notfound"}, false, "") assert.Contains(t, desc, "not named") assert.Contains(t, desc, "excluded") assert.Contains(t, desc, "or") assert.Contains(t, desc, "notfound") container := new(mocks.FilterableContainer) container.On("Name").Return("Another") container.On("Enabled").Return(false, false) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("AnotherOne") container.On("Enabled").Return(true, true) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("test") container.On("Enabled").Return(false, false) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("excluded") container.On("Enabled").Return(true, true) assert.False(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("excludedAsSubstring") container.On("Enabled").Return(true, true) assert.True(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Name").Return("notfound") container.On("Enabled").Return(true, true) assert.False(t, filter(container)) container.AssertExpectations(t) container = new(mocks.FilterableContainer) container.On("Enabled").Return(false, true) assert.False(t, filter(container)) container.AssertExpectations(t) } ================================================ FILE: pkg/lifecycle/lifecycle.go ================================================ package lifecycle import ( "github.com/containrrr/watchtower/pkg/container" "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" ) // ExecutePreChecks tries to run the pre-check lifecycle hook for all containers included by the current filter. func ExecutePreChecks(client container.Client, params types.UpdateParams) { containers, err := client.ListContainers(params.Filter) if err != nil { return } for _, currentContainer := range containers { ExecutePreCheckCommand(client, currentContainer) } } // ExecutePostChecks tries to run the post-check lifecycle hook for all containers included by the current filter. func ExecutePostChecks(client container.Client, params types.UpdateParams) { containers, err := client.ListContainers(params.Filter) if err != nil { return } for _, currentContainer := range containers { ExecutePostCheckCommand(client, currentContainer) } } // ExecutePreCheckCommand tries to run the pre-check lifecycle hook for a single container. func ExecutePreCheckCommand(client container.Client, container types.Container) { clog := log.WithField("container", container.Name()) command := container.GetLifecyclePreCheckCommand() if len(command) == 0 { clog.Debug("No pre-check command supplied. Skipping") return } clog.Debug("Executing pre-check command.") _, err := client.ExecuteCommand(container.ID(), command, 1) if err != nil { clog.Error(err) } } // ExecutePostCheckCommand tries to run the post-check lifecycle hook for a single container. func ExecutePostCheckCommand(client container.Client, container types.Container) { clog := log.WithField("container", container.Name()) command := container.GetLifecyclePostCheckCommand() if len(command) == 0 { clog.Debug("No post-check command supplied. Skipping") return } clog.Debug("Executing post-check command.") _, err := client.ExecuteCommand(container.ID(), command, 1) if err != nil { clog.Error(err) } } // ExecutePreUpdateCommand tries to run the pre-update lifecycle hook for a single container. func ExecutePreUpdateCommand(client container.Client, container types.Container) (SkipUpdate bool, err error) { timeout := container.PreUpdateTimeout() command := container.GetLifecyclePreUpdateCommand() clog := log.WithField("container", container.Name()) if len(command) == 0 { clog.Debug("No pre-update command supplied. Skipping") return false, nil } if !container.IsRunning() || container.IsRestarting() { clog.Debug("Container is not running. Skipping pre-update command.") return false, nil } clog.Debug("Executing pre-update command.") return client.ExecuteCommand(container.ID(), command, timeout) } // ExecutePostUpdateCommand tries to run the post-update lifecycle hook for a single container. func ExecutePostUpdateCommand(client container.Client, newContainerID types.ContainerID) { newContainer, err := client.GetContainer(newContainerID) timeout := newContainer.PostUpdateTimeout() if err != nil { log.WithField("containerID", newContainerID.ShortID()).Error(err) return } clog := log.WithField("container", newContainer.Name()) command := newContainer.GetLifecyclePostUpdateCommand() if len(command) == 0 { clog.Debug("No post-update command supplied. Skipping") return } clog.Debug("Executing post-update command.") _, err = client.ExecuteCommand(newContainerID, command, timeout) if err != nil { clog.Error(err) } } ================================================ FILE: pkg/metrics/metrics.go ================================================ package metrics import ( "github.com/containrrr/watchtower/pkg/types" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var metrics *Metrics // Metric is the data points of a single scan type Metric struct { Scanned int Updated int Failed int } // Metrics is the handler processing all individual scan metrics type Metrics struct { channel chan *Metric scanned prometheus.Gauge updated prometheus.Gauge failed prometheus.Gauge total prometheus.Counter skipped prometheus.Counter } // NewMetric returns a Metric with the counts taken from the appropriate types.Report fields func NewMetric(report types.Report) *Metric { return &Metric{ Scanned: len(report.Scanned()), // Note: This is for backwards compatibility. ideally, stale containers should be counted separately Updated: len(report.Updated()) + len(report.Stale()), Failed: len(report.Failed()), } } // QueueIsEmpty checks whether any messages are enqueued in the channel func (metrics *Metrics) QueueIsEmpty() bool { return len(metrics.channel) == 0 } // Register registers metrics for an executed scan func (metrics *Metrics) Register(metric *Metric) { metrics.channel <- metric } // Default creates a new metrics handler if none exists, otherwise returns the existing one func Default() *Metrics { if metrics != nil { return metrics } metrics = &Metrics{ scanned: promauto.NewGauge(prometheus.GaugeOpts{ Name: "watchtower_containers_scanned", Help: "Number of containers scanned for changes by watchtower during the last scan", }), updated: promauto.NewGauge(prometheus.GaugeOpts{ Name: "watchtower_containers_updated", Help: "Number of containers updated by watchtower during the last scan", }), failed: promauto.NewGauge(prometheus.GaugeOpts{ Name: "watchtower_containers_failed", Help: "Number of containers where update failed during the last scan", }), total: promauto.NewCounter(prometheus.CounterOpts{ Name: "watchtower_scans_total", Help: "Number of scans since the watchtower started", }), skipped: promauto.NewCounter(prometheus.CounterOpts{ Name: "watchtower_scans_skipped", Help: "Number of skipped scans since watchtower started", }), channel: make(chan *Metric, 10), } go metrics.HandleUpdate(metrics.channel) return metrics } // RegisterScan fetches a metric handler and enqueues a metric func RegisterScan(metric *Metric) { metrics := Default() metrics.Register(metric) } // HandleUpdate dequeue the metric channel and processes it func (metrics *Metrics) HandleUpdate(channel <-chan *Metric) { for change := range channel { if change == nil { // Update was skipped and rescheduled metrics.total.Inc() metrics.skipped.Inc() metrics.scanned.Set(0) metrics.updated.Set(0) metrics.failed.Set(0) continue } // Update metrics with the new values metrics.total.Inc() metrics.scanned.Set(float64(change.Scanned)) metrics.updated.Set(float64(change.Updated)) metrics.failed.Set(float64(change.Failed)) } } ================================================ FILE: pkg/notifications/common_templates.go ================================================ package notifications var commonTemplates = map[string]string{ `default-legacy`: "{{range .}}{{.Message}}{{println}}{{end}}", `default`: ` {{- if .Report -}} {{- with .Report -}} {{- if ( or .Updated .Failed ) -}} {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed {{- range .Updated}} - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}} {{- end -}} {{- range .Fresh}} - {{.Name}} ({{.ImageName}}): {{.State}} {{- end -}} {{- range .Skipped}} - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} {{- end -}} {{- range .Failed}} - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} {{- end -}} {{- end -}} {{- end -}} {{- else -}} {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}} {{- end -}}`, `porcelain.v1.summary-no-log`: ` {{- if .Report -}} {{- range .Report.All }} {{- .Name}} ({{.ImageName}}): {{.State -}} {{- with .Error}} Error: {{.}}{{end}}{{ println }} {{- else -}} no containers matched filter {{- end -}} {{- end -}}`, `json.v1`: `{{ . | ToJSON }}`, } ================================================ FILE: pkg/notifications/email.go ================================================ package notifications import ( "time" "github.com/spf13/cobra" shoutrrrSmtp "github.com/containrrr/shoutrrr/pkg/services/smtp" t "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" ) const ( emailType = "email" ) type emailTypeNotifier struct { From, To string Server, User, Password string Port int tlsSkipVerify bool entries []*log.Entry delay time.Duration } func newEmailNotifier(c *cobra.Command) t.ConvertibleNotifier { flags := c.Flags() from, _ := flags.GetString("notification-email-from") to, _ := flags.GetString("notification-email-to") server, _ := flags.GetString("notification-email-server") user, _ := flags.GetString("notification-email-server-user") password, _ := flags.GetString("notification-email-server-password") port, _ := flags.GetInt("notification-email-server-port") tlsSkipVerify, _ := flags.GetBool("notification-email-server-tls-skip-verify") delay, _ := flags.GetInt("notification-email-delay") n := &emailTypeNotifier{ entries: []*log.Entry{}, From: from, To: to, Server: server, User: user, Password: password, Port: port, tlsSkipVerify: tlsSkipVerify, delay: time.Duration(delay) * time.Second, } return n } func (e *emailTypeNotifier) GetURL(c *cobra.Command) (string, error) { conf := &shoutrrrSmtp.Config{ FromAddress: e.From, FromName: "Watchtower", ToAddresses: []string{e.To}, Port: uint16(e.Port), Host: e.Server, Username: e.User, Password: e.Password, UseStartTLS: !e.tlsSkipVerify, UseHTML: false, Encryption: shoutrrrSmtp.EncMethods.Auto, Auth: shoutrrrSmtp.AuthTypes.None, ClientHost: "localhost", } if len(e.User) > 0 { conf.Auth = shoutrrrSmtp.AuthTypes.Plain } if e.tlsSkipVerify { conf.Encryption = shoutrrrSmtp.EncMethods.None } return conf.GetURL().String(), nil } func (e *emailTypeNotifier) GetDelay() time.Duration { return e.delay } ================================================ FILE: pkg/notifications/gotify.go ================================================ package notifications import ( "net/url" "strings" shoutrrrGotify "github.com/containrrr/shoutrrr/pkg/services/gotify" t "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" ) const ( gotifyType = "gotify" ) type gotifyTypeNotifier struct { gotifyURL string gotifyAppToken string gotifyInsecureSkipVerify bool } func newGotifyNotifier(c *cobra.Command) t.ConvertibleNotifier { flags := c.Flags() apiURL := getGotifyURL(flags) token := getGotifyToken(flags) skipVerify, _ := flags.GetBool("notification-gotify-tls-skip-verify") n := &gotifyTypeNotifier{ gotifyURL: apiURL, gotifyAppToken: token, gotifyInsecureSkipVerify: skipVerify, } return n } func getGotifyToken(flags *pflag.FlagSet) string { gotifyToken, _ := flags.GetString("notification-gotify-token") if len(gotifyToken) < 1 { log.Fatal("Required argument --notification-gotify-token(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN(env) is empty.") } return gotifyToken } func getGotifyURL(flags *pflag.FlagSet) string { gotifyURL, _ := flags.GetString("notification-gotify-url") if len(gotifyURL) < 1 { log.Fatal("Required argument --notification-gotify-url(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_URL(env) is empty.") } else if !(strings.HasPrefix(gotifyURL, "http://") || strings.HasPrefix(gotifyURL, "https://")) { log.Fatal("Gotify URL must start with \"http://\" or \"https://\"") } else if strings.HasPrefix(gotifyURL, "http://") { log.Warn("Using an HTTP url for Gotify is insecure") } return gotifyURL } func (n *gotifyTypeNotifier) GetURL(c *cobra.Command) (string, error) { apiURL, err := url.Parse(n.gotifyURL) if err != nil { return "", err } config := &shoutrrrGotify.Config{ Host: apiURL.Host, Path: apiURL.Path, DisableTLS: apiURL.Scheme == "http", Token: n.gotifyAppToken, } return config.GetURL().String(), nil } ================================================ FILE: pkg/notifications/json.go ================================================ package notifications import ( "encoding/json" t "github.com/containrrr/watchtower/pkg/types" ) type jsonMap = map[string]interface{} // MarshalJSON implements json.Marshaler func (d Data) MarshalJSON() ([]byte, error) { var entries = make([]jsonMap, len(d.Entries)) for i, entry := range d.Entries { entries[i] = jsonMap{ `level`: entry.Level, `message`: entry.Message, `data`: entry.Data, `time`: entry.Time, } } var report jsonMap if d.Report != nil { report = jsonMap{ `scanned`: marshalReports(d.Report.Scanned()), `updated`: marshalReports(d.Report.Updated()), `failed`: marshalReports(d.Report.Failed()), `skipped`: marshalReports(d.Report.Skipped()), `stale`: marshalReports(d.Report.Stale()), `fresh`: marshalReports(d.Report.Fresh()), } } return json.Marshal(jsonMap{ `report`: report, `title`: d.Title, `host`: d.Host, `entries`: entries, }) } func marshalReports(reports []t.ContainerReport) []jsonMap { jsonReports := make([]jsonMap, len(reports)) for i, report := range reports { jsonReports[i] = jsonMap{ `id`: report.ID().ShortID(), `name`: report.Name(), `currentImageId`: report.CurrentImageID().ShortID(), `latestImageId`: report.LatestImageID().ShortID(), `imageName`: report.ImageName(), `state`: report.State(), } if errorMessage := report.Error(); errorMessage != "" { jsonReports[i][`error`] = errorMessage } } return jsonReports } var _ json.Marshaler = &Data{} ================================================ FILE: pkg/notifications/json_test.go ================================================ package notifications import ( s "github.com/containrrr/watchtower/pkg/session" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) var _ = Describe("JSON template", func() { When("using report templates", func() { When("JSON template is used", func() { It("should format the messages to the expected format", func() { expected := `{ "entries": [ { "data": null, "level": "info", "message": "foo Bar", "time": "0001-01-01T00:00:00Z" } ], "host": "Mock", "report": { "failed": [ { "currentImageId": "01d210000000", "error": "accidentally the whole container", "id": "c79210000000", "imageName": "mock/fail1:latest", "latestImageId": "d0a210000000", "name": "fail1", "state": "Failed" } ], "fresh": [ { "currentImageId": "01d310000000", "id": "c79310000000", "imageName": "mock/frsh1:latest", "latestImageId": "01d310000000", "name": "frsh1", "state": "Fresh" } ], "scanned": [ { "currentImageId": "01d110000000", "id": "c79110000000", "imageName": "mock/updt1:latest", "latestImageId": "d0a110000000", "name": "updt1", "state": "Updated" }, { "currentImageId": "01d120000000", "id": "c79120000000", "imageName": "mock/updt2:latest", "latestImageId": "d0a120000000", "name": "updt2", "state": "Updated" }, { "currentImageId": "01d210000000", "error": "accidentally the whole container", "id": "c79210000000", "imageName": "mock/fail1:latest", "latestImageId": "d0a210000000", "name": "fail1", "state": "Failed" }, { "currentImageId": "01d310000000", "id": "c79310000000", "imageName": "mock/frsh1:latest", "latestImageId": "01d310000000", "name": "frsh1", "state": "Fresh" } ], "skipped": [ { "currentImageId": "01d410000000", "error": "unpossible", "id": "c79410000000", "imageName": "mock/skip1:latest", "latestImageId": "01d410000000", "name": "skip1", "state": "Skipped" } ], "stale": [], "updated": [ { "currentImageId": "01d110000000", "id": "c79110000000", "imageName": "mock/updt1:latest", "latestImageId": "d0a110000000", "name": "updt1", "state": "Updated" }, { "currentImageId": "01d120000000", "id": "c79120000000", "imageName": "mock/updt2:latest", "latestImageId": "d0a120000000", "name": "updt2", "state": "Updated" } ] }, "title": "Watchtower updates on Mock" }` data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState) Expect(getTemplatedResult(`json.v1`, false, data)).To(MatchJSON(expected)) }) }) }) }) ================================================ FILE: pkg/notifications/model.go ================================================ package notifications import ( t "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" ) // StaticData is the part of the notification template data model set upon initialization type StaticData struct { Title string Host string } // Data is the notification template data model type Data struct { StaticData Entries []*log.Entry Report t.Report } ================================================ FILE: pkg/notifications/msteams.go ================================================ package notifications import ( "net/url" shoutrrrTeams "github.com/containrrr/shoutrrr/pkg/services/teams" t "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) const ( msTeamsType = "msteams" ) type msTeamsTypeNotifier struct { webHookURL string data bool } func newMsTeamsNotifier(cmd *cobra.Command) t.ConvertibleNotifier { flags := cmd.Flags() webHookURL, _ := flags.GetString("notification-msteams-hook") if len(webHookURL) <= 0 { log.Fatal("Required argument --notification-msteams-hook(cli) or WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL(env) is empty.") } withData, _ := flags.GetBool("notification-msteams-data") n := &msTeamsTypeNotifier{ webHookURL: webHookURL, data: withData, } return n } func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command) (string, error) { webhookURL, err := url.Parse(n.webHookURL) if err != nil { return "", err } config, err := shoutrrrTeams.ConfigFromWebhookURL(*webhookURL) if err != nil { return "", err } config.Color = ColorHex return config.GetURL().String(), nil } ================================================ FILE: pkg/notifications/notifications_suite_test.go ================================================ package notifications_test import ( "github.com/onsi/gomega/format" "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestNotifications(t *testing.T) { RegisterFailHandler(Fail) format.CharactersAroundMismatchToInclude = 20 RunSpecs(t, "Notifications Suite") } ================================================ FILE: pkg/notifications/notifier.go ================================================ package notifications import ( "os" "strings" "time" ty "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) // NewNotifier creates and returns a new Notifier, using global configuration. func NewNotifier(c *cobra.Command) ty.Notifier { f := c.Flags() level, _ := f.GetString("notifications-level") logLevel, err := log.ParseLevel(level) if err != nil { log.Fatalf("Notifications invalid log level: %s", err.Error()) } reportTemplate, _ := f.GetBool("notification-report") stdout, _ := f.GetBool("notification-log-stdout") tplString, _ := f.GetString("notification-template") urls, _ := f.GetStringArray("notification-url") data := GetTemplateData(c) urls, delay := AppendLegacyUrls(urls, c) return createNotifier(urls, logLevel, tplString, !reportTemplate, data, stdout, delay) } // AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags func AppendLegacyUrls(urls []string, cmd *cobra.Command) ([]string, time.Duration) { // Parse types and create notifiers. types, err := cmd.Flags().GetStringSlice("notifications") if err != nil { log.WithError(err).Fatal("could not read notifications argument") } legacyDelay := time.Duration(0) for _, t := range types { var legacyNotifier ty.ConvertibleNotifier var err error switch t { case emailType: legacyNotifier = newEmailNotifier(cmd) case slackType: legacyNotifier = newSlackNotifier(cmd) case msTeamsType: legacyNotifier = newMsTeamsNotifier(cmd) case gotifyType: legacyNotifier = newGotifyNotifier(cmd) case shoutrrrType: continue default: log.Fatalf("Unknown notification type %q", t) // Not really needed, used for nil checking static analysis continue } shoutrrrURL, err := legacyNotifier.GetURL(cmd) if err != nil { log.Fatal("failed to create notification config: ", err) } urls = append(urls, shoutrrrURL) if delayNotifier, ok := legacyNotifier.(ty.DelayNotifier); ok { legacyDelay = delayNotifier.GetDelay() } log.WithField("URL", shoutrrrURL).Trace("created Shoutrrr URL from legacy notifier") } delay := GetDelay(cmd, legacyDelay) return urls, delay } // GetDelay returns the legacy delay if defined, otherwise the delay as set by args is returned func GetDelay(c *cobra.Command, legacyDelay time.Duration) time.Duration { if legacyDelay > 0 { return legacyDelay } delay, _ := c.PersistentFlags().GetInt("notifications-delay") if delay > 0 { return time.Duration(delay) * time.Second } return time.Duration(0) } // GetTitle formats the title based on the passed hostname and tag func GetTitle(hostname string, tag string) string { tb := strings.Builder{} if tag != "" { tb.WriteRune('[') tb.WriteString(tag) tb.WriteRune(']') tb.WriteRune(' ') } tb.WriteString("Watchtower updates") if hostname != "" { tb.WriteString(" on ") tb.WriteString(hostname) } return tb.String() } // GetTemplateData populates the static notification data from flags and environment func GetTemplateData(c *cobra.Command) StaticData { f := c.PersistentFlags() hostname, _ := f.GetString("notifications-hostname") if hostname == "" { hostname, _ = os.Hostname() } title := "" if skip, _ := f.GetBool("notification-skip-title"); !skip { tag, _ := f.GetString("notification-title-tag") if tag == "" { // For legacy email support tag, _ = f.GetString("notification-email-subjecttag") } title = GetTitle(hostname, tag) } return StaticData{ Host: hostname, Title: title, } } // ColorHex is the default notification color used for services that support it (formatted as a CSS hex string) const ColorHex = "#406170" // ColorInt is the default notification color used for services that support it (as an int value) const ColorInt = 0x406170 ================================================ FILE: pkg/notifications/notifier_test.go ================================================ package notifications_test import ( "fmt" "net/url" "time" "github.com/containrrr/watchtower/cmd" "github.com/containrrr/watchtower/internal/flags" "github.com/containrrr/watchtower/pkg/notifications" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) var _ = Describe("notifications", func() { Describe("the notifier", func() { When("only empty notifier types are provided", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) err := command.ParseFlags([]string{ "--notifications", "shoutrrr", }) Expect(err).NotTo(HaveOccurred()) notif := notifications.NewNotifier(command) Expect(notif.GetNames()).To(BeEmpty()) }) When("title is overriden in flag", func() { It("should use the specified hostname in the title", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) err := command.ParseFlags([]string{ "--notifications-hostname", "test.host", }) Expect(err).NotTo(HaveOccurred()) data := notifications.GetTemplateData(command) title := data.Title Expect(title).To(Equal("Watchtower updates on test.host")) }) }) When("no hostname can be resolved", func() { It("should use the default simple title", func() { title := notifications.GetTitle("", "") Expect(title).To(Equal("Watchtower updates")) }) }) When("title tag is set", func() { It("should use the prefix in the title", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) Expect(command.ParseFlags([]string{ "--notification-title-tag", "PREFIX", })).To(Succeed()) data := notifications.GetTemplateData(command) Expect(data.Title).To(HavePrefix("[PREFIX]")) }) }) When("legacy email tag is set", func() { It("should use the prefix in the title", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) Expect(command.ParseFlags([]string{ "--notification-email-subjecttag", "PREFIX", })).To(Succeed()) data := notifications.GetTemplateData(command) Expect(data.Title).To(HavePrefix("[PREFIX]")) }) }) When("the skip title flag is set", func() { It("should return an empty title", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) Expect(command.ParseFlags([]string{ "--notification-skip-title", })).To(Succeed()) data := notifications.GetTemplateData(command) Expect(data.Title).To(BeEmpty()) }) }) When("no delay is defined", func() { It("should use the default delay", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) delay := notifications.GetDelay(command, time.Duration(0)) Expect(delay).To(Equal(time.Duration(0))) }) }) When("delay is defined", func() { It("should use the specified delay", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) err := command.ParseFlags([]string{ "--notifications-delay", "5", }) Expect(err).NotTo(HaveOccurred()) delay := notifications.GetDelay(command, time.Duration(0)) Expect(delay).To(Equal(time.Duration(5) * time.Second)) }) }) When("legacy delay is defined", func() { It("should use the specified legacy delay", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) delay := notifications.GetDelay(command, time.Duration(5)*time.Second) Expect(delay).To(Equal(time.Duration(5) * time.Second)) }) }) When("legacy delay and delay is defined", func() { It("should use the specified legacy delay and ignore the specified delay", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) err := command.ParseFlags([]string{ "--notifications-delay", "0", }) Expect(err).NotTo(HaveOccurred()) delay := notifications.GetDelay(command, time.Duration(7)*time.Second) Expect(delay).To(Equal(time.Duration(7) * time.Second)) }) }) }) Describe("the slack notifier", func() { // builderFn := notifications.NewSlackNotifier When("passing a discord url to the slack notifier", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) channel := "123456789" token := "abvsihdbau" color := notifications.ColorInt username := "containrrrbot" iconURL := "https://containrrr.dev/watchtower-sq180.png" expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&username=watchtower", token, channel, color) buildArgs := func(url string) []string { return []string{ "--notifications", "slack", "--notification-slack-hook-url", url, } } It("should return a discord url when using a hook url with the domain discord.com", func() { hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token) testURL(buildArgs(hookURL), expected, time.Duration(0)) }) It("should return a discord url when using a hook url with the domain discordapp.com", func() { hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discordapp.com", channel, token) testURL(buildArgs(hookURL), expected, time.Duration(0)) }) When("icon URL and username are specified", func() { It("should return the expected URL", func() { hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token) expectedOutput := fmt.Sprintf("discord://%s@%s?avatar=%s&color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&username=%s", token, channel, url.QueryEscape(iconURL), color, username) expectedDelay := time.Duration(7) * time.Second args := []string{ "--notifications", "slack", "--notification-slack-hook-url", hookURL, "--notification-slack-identifier", username, "--notification-slack-icon-url", iconURL, "--notifications-delay", fmt.Sprint(expectedDelay.Seconds()), } testURL(args, expectedOutput, expectedDelay) }) }) }) When("converting a slack service config into a shoutrrr url", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) username := "containrrrbot" tokenA := "AAAAAAAAA" tokenB := "BBBBBBBBB" tokenC := "123456789123456789123456" color := url.QueryEscape(notifications.ColorHex) iconURL := "https://containrrr.dev/watchtower-sq180.png" iconEmoji := "whale" When("icon URL is specified", func() { It("should return the expected URL", func() { hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC) expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s", tokenA, tokenB, tokenC, username, color, url.QueryEscape(iconURL)) expectedDelay := time.Duration(7) * time.Second args := []string{ "--notifications", "slack", "--notification-slack-hook-url", hookURL, "--notification-slack-identifier", username, "--notification-slack-icon-url", iconURL, "--notifications-delay", fmt.Sprint(expectedDelay.Seconds()), } testURL(args, expectedOutput, expectedDelay) }) }) When("icon emoji is specified", func() { It("should return the expected URL", func() { hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC) expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s", tokenA, tokenB, tokenC, username, color, iconEmoji) args := []string{ "--notifications", "slack", "--notification-slack-hook-url", hookURL, "--notification-slack-identifier", username, "--notification-slack-icon-emoji", iconEmoji, } testURL(args, expectedOutput, time.Duration(0)) }) }) }) }) Describe("the gotify notifier", func() { When("converting a gotify service config into a shoutrrr url", func() { It("should return the expected URL", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) token := "aaa" host := "shoutrrr.local" expectedOutput := fmt.Sprintf("gotify://%s/%s?title=", host, token) args := []string{ "--notifications", "gotify", "--notification-gotify-url", fmt.Sprintf("https://%s", host), "--notification-gotify-token", token, } testURL(args, expectedOutput, time.Duration(0)) }) }) }) Describe("the teams notifier", func() { When("converting a teams service config into a shoutrrr url", func() { It("should return the expected URL", func() { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) tokenA := "11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc" tokenB := "33333333012222222222333333333344" tokenC := "44444444-4444-4444-8444-cccccccccccc" color := url.QueryEscape(notifications.ColorHex) hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC) expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s", tokenA, tokenB, tokenC, color) args := []string{ "--notifications", "msteams", "--notification-msteams-hook", hookURL, } testURL(args, expectedOutput, time.Duration(0)) }) }) }) Describe("the email notifier", func() { When("converting an email service config into a shoutrrr url", func() { It("should set the from address in the URL", func() { fromAddress := "lala@example.com" expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, "mail@example.com", "Plain") expectedDelay := time.Duration(7) * time.Second args := []string{ "--notifications", "email", "--notification-email-from", fromAddress, "--notification-email-to", "mail@example.com", "--notification-email-server-user", "containrrrbot", "--notification-email-server-password", "secret-password", "--notification-email-server", "mail.containrrr.dev", "--notifications-delay", fmt.Sprint(expectedDelay.Seconds()), } testURL(args, expectedOutput, expectedDelay) }) It("should return the expected URL", func() { fromAddress := "sender@example.com" toAddress := "receiver@example.com" expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, toAddress, "Plain") expectedDelay := time.Duration(7) * time.Second args := []string{ "--notifications", "email", "--notification-email-from", fromAddress, "--notification-email-to", toAddress, "--notification-email-server-user", "containrrrbot", "--notification-email-server-password", "secret-password", "--notification-email-server", "mail.containrrr.dev", "--notification-email-delay", fmt.Sprint(expectedDelay.Seconds()), } testURL(args, expectedOutput, expectedDelay) }) }) }) }) func buildExpectedURL(username string, password string, host string, port int, from string, to string, auth string) string { var template = "smtp://%s:%s@%s:%d/?auth=%s&fromaddress=%s&fromname=Watchtower&subject=&toaddresses=%s" return fmt.Sprintf(template, url.QueryEscape(username), url.QueryEscape(password), host, port, auth, url.QueryEscape(from), url.QueryEscape(to)) } func testURL(args []string, expectedURL string, expectedDelay time.Duration) { defer GinkgoRecover() command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) Expect(command.ParseFlags(args)).To(Succeed()) urls, delay := notifications.AppendLegacyUrls([]string{}, command) Expect(urls).To(ContainElement(expectedURL)) Expect(delay).To(Equal(expectedDelay)) } ================================================ FILE: pkg/notifications/preview/data/data.go ================================================ package data import ( "encoding/hex" "errors" "math/rand" "strconv" "time" "github.com/containrrr/watchtower/pkg/types" ) type previewData struct { rand *rand.Rand lastTime time.Time report *report containerCount int Entries []*logEntry StaticData staticData } type staticData struct { Title string Host string } // New initializes a new preview data struct func New() *previewData { return &previewData{ rand: rand.New(rand.NewSource(1)), lastTime: time.Now().Add(-30 * time.Minute), report: nil, containerCount: 0, Entries: []*logEntry{}, StaticData: staticData{ Title: "Title", Host: "Host", }, } } // AddFromState adds a container status entry to the report with the given state func (pb *previewData) AddFromState(state State) { cid := types.ContainerID(pb.generateID()) old := types.ImageID(pb.generateID()) new := types.ImageID(pb.generateID()) name := pb.generateName() image := pb.generateImageName(name) var err error if state == FailedState { err = errors.New(pb.randomEntry(errorMessages)) } else if state == SkippedState { err = errors.New(pb.randomEntry(skippedMessages)) } pb.addContainer(containerStatus{ containerID: cid, oldImage: old, newImage: new, containerName: name, imageName: image, error: err, state: state, }) } func (pb *previewData) addContainer(c containerStatus) { if pb.report == nil { pb.report = &report{} } switch c.state { case ScannedState: pb.report.scanned = append(pb.report.scanned, &c) case UpdatedState: pb.report.updated = append(pb.report.updated, &c) case FailedState: pb.report.failed = append(pb.report.failed, &c) case SkippedState: pb.report.skipped = append(pb.report.skipped, &c) case StaleState: pb.report.stale = append(pb.report.stale, &c) case FreshState: pb.report.fresh = append(pb.report.fresh, &c) default: return } pb.containerCount += 1 } // AddLogEntry adds a preview log entry of the given level func (pd *previewData) AddLogEntry(level LogLevel) { var msg string switch level { case FatalLevel: fallthrough case ErrorLevel: fallthrough case WarnLevel: msg = pd.randomEntry(logErrors) default: msg = pd.randomEntry(logMessages) } pd.Entries = append(pd.Entries, &logEntry{ Message: msg, Data: map[string]any{}, Time: pd.generateTime(), Level: level, }) } // Report returns a preview report func (pb *previewData) Report() types.Report { return pb.report } func (pb *previewData) generateID() string { buf := make([]byte, 32) _, _ = pb.rand.Read(buf) return hex.EncodeToString(buf) } func (pb *previewData) generateTime() time.Time { pb.lastTime = pb.lastTime.Add(time.Duration(pb.rand.Intn(30)) * time.Second) return pb.lastTime } func (pb *previewData) randomEntry(arr []string) string { return arr[pb.rand.Intn(len(arr))] } func (pb *previewData) generateName() string { index := pb.containerCount if index <= len(containerNames) { return "/" + containerNames[index] } suffix := index / len(containerNames) index %= len(containerNames) return "/" + containerNames[index] + strconv.FormatInt(int64(suffix), 10) } func (pb *previewData) generateImageName(name string) string { index := pb.containerCount % len(organizationNames) return organizationNames[index] + name + ":latest" } ================================================ FILE: pkg/notifications/preview/data/logs.go ================================================ package data import ( "time" ) type logEntry struct { Message string Data map[string]any Time time.Time Level LogLevel } // LogLevel is the analog of logrus.Level type LogLevel string const ( TraceLevel LogLevel = "trace" DebugLevel LogLevel = "debug" InfoLevel LogLevel = "info" WarnLevel LogLevel = "warning" ErrorLevel LogLevel = "error" FatalLevel LogLevel = "fatal" PanicLevel LogLevel = "panic" ) // LevelsFromString parses a string of level characters and returns a slice of the corresponding log levels func LevelsFromString(str string) []LogLevel { levels := make([]LogLevel, 0, len(str)) for _, c := range str { switch c { case 'p': levels = append(levels, PanicLevel) case 'f': levels = append(levels, FatalLevel) case 'e': levels = append(levels, ErrorLevel) case 'w': levels = append(levels, WarnLevel) case 'i': levels = append(levels, InfoLevel) case 'd': levels = append(levels, DebugLevel) case 't': levels = append(levels, TraceLevel) default: continue } } return levels } // String returns the log level as a string func (level LogLevel) String() string { return string(level) } ================================================ FILE: pkg/notifications/preview/data/preview_strings.go ================================================ package data var containerNames = []string{ "cyberscribe", "datamatrix", "nexasync", "quantumquill", "aerosphere", "virtuos", "fusionflow", "neuralink", "pixelpulse", "synthwave", "codecraft", "zapzone", "robologic", "dreamstream", "infinisync", "megamesh", "novalink", "xenogenius", "ecosim", "innovault", "techtracer", "fusionforge", "quantumquest", "neuronest", "codefusion", "datadyno", "pixelpioneer", "vortexvision", "cybercraft", "synthsphere", "infinitescript", "roborhythm", "dreamengine", "aquasync", "geniusgrid", "megamind", "novasync-pro", "xenonwave", "ecologic", "innoscan", } var organizationNames = []string{ "techwave", "codecrafters", "innotechlabs", "fusionsoft", "cyberpulse", "quantumscribe", "datadynamo", "neuralink", "pixelpro", "synthwizards", "virtucorplabs", "robologic", "dreamstream", "novanest", "megamind", "xenonwave", "ecologic", "innosync", "techgenius", "nexasoft", "codewave", "zapzone", "techsphere", "aquatech", "quantumcraft", "neuronest", "datafusion", "pixelpioneer", "synthsphere", "infinitescribe", "roborhythm", "dreamengine", "vortexvision", "geniusgrid", "megamesh", "novasync", "xenogeniuslabs", "ecosim", "innovault", } var errorMessages = []string{ "Error 404: Resource not found", "Critical Error: System meltdown imminent", "Error 500: Internal server error", "Invalid input: Please check your data", "Access denied: Unauthorized access detected", "Network connection lost: Please check your connection", "Error 403: Forbidden access", "Fatal error: System crash imminent", "File not found: Check the file path", "Invalid credentials: Authentication failed", "Error 502: Bad Gateway", "Database connection failed: Please try again later", "Security breach detected: Take immediate action", "Error 400: Bad request", "Out of memory: Close unnecessary applications", "Invalid configuration: Check your settings", "Error 503: Service unavailable", "File is read-only: Cannot modify", "Data corruption detected: Backup your data", "Error 401: Unauthorized", "Disk space full: Free up disk space", "Connection timeout: Retry your request", "Error 504: Gateway timeout", "File access denied: Permission denied", "Unexpected error: Please contact support", "Error 429: Too many requests", "Invalid URL: Check the URL format", "Database query failed: Try again later", "Error 408: Request timeout", "File is in use: Close the file and try again", "Invalid parameter: Check your input", "Error 502: Proxy error", "Database connection lost: Reconnect and try again", "File size exceeds limit: Reduce the file size", "Error 503: Overloaded server", "Operation aborted: Try again", "Invalid API key: Check your API key", "Error 507: Insufficient storage", "Database deadlock: Retry your transaction", "Error 405: Method not allowed", "File format not supported: Choose a different format", "Unknown error: Contact system administrator", } var skippedMessages = []string{ "Fear of introducing new bugs", "Don't have time for the update process", "Current version works fine for my needs", "Concerns about compatibility with other software", "Limited bandwidth for downloading updates", "Worries about losing custom settings or configurations", "Lack of trust in the software developer's updates", "Dislike changes to the user interface", "Avoiding potential subscription fees", "Suspicion of hidden data collection in updates", "Apprehension about changes in privacy policies", "Prefer the older version's features or design", "Worry about software becoming more resource-intensive", "Avoiding potential changes in licensing terms", "Waiting for initial bugs to be resolved in the update", "Concerns about update breaking third-party plugins or extensions", "Belief that the software is already secure enough", "Don't want to relearn how to use the software", "Fear of losing access to older file formats", "Avoiding the hassle of having to update multiple devices", } var logMessages = []string{ "Checking for available updates...", "Downloading update package...", "Verifying update integrity...", "Preparing to install update...", "Backing up existing configuration...", "Installing update...", "Update installation complete.", "Applying configuration settings...", "Cleaning up temporary files...", "Update successful! Software is now up-to-date.", "Restarting the application...", "Restart complete. Enjoy the latest features!", "Update rollback complete. Your software remains at the previous version.", } var logErrors = []string{ "Unable to check for updates. Please check your internet connection.", "Update package download failed. Try again later.", "Update verification failed. Please contact support.", "Update installation failed. Rolling back to the previous version...", "Your configuration settings may have been reset to defaults.", } ================================================ FILE: pkg/notifications/preview/data/report.go ================================================ package data import ( "sort" "github.com/containrrr/watchtower/pkg/types" ) // State is the outcome of a container in a session report type State string const ( ScannedState State = "scanned" UpdatedState State = "updated" FailedState State = "failed" SkippedState State = "skipped" StaleState State = "stale" FreshState State = "fresh" ) // StatesFromString parses a string of state characters and returns a slice of the corresponding report states func StatesFromString(str string) []State { states := make([]State, 0, len(str)) for _, c := range str { switch c { case 'c': states = append(states, ScannedState) case 'u': states = append(states, UpdatedState) case 'e': states = append(states, FailedState) case 'k': states = append(states, SkippedState) case 't': states = append(states, StaleState) case 'f': states = append(states, FreshState) default: continue } } return states } type report struct { scanned []types.ContainerReport updated []types.ContainerReport failed []types.ContainerReport skipped []types.ContainerReport stale []types.ContainerReport fresh []types.ContainerReport } func (r *report) Scanned() []types.ContainerReport { return r.scanned } func (r *report) Updated() []types.ContainerReport { return r.updated } func (r *report) Failed() []types.ContainerReport { return r.failed } func (r *report) Skipped() []types.ContainerReport { return r.skipped } func (r *report) Stale() []types.ContainerReport { return r.stale } func (r *report) Fresh() []types.ContainerReport { return r.fresh } func (r *report) All() []types.ContainerReport { allLen := len(r.scanned) + len(r.updated) + len(r.failed) + len(r.skipped) + len(r.stale) + len(r.fresh) all := make([]types.ContainerReport, 0, allLen) presentIds := map[types.ContainerID][]string{} appendUnique := func(reports []types.ContainerReport) { for _, cr := range reports { if _, found := presentIds[cr.ID()]; found { continue } all = append(all, cr) presentIds[cr.ID()] = nil } } appendUnique(r.updated) appendUnique(r.failed) appendUnique(r.skipped) appendUnique(r.stale) appendUnique(r.fresh) appendUnique(r.scanned) sort.Sort(sortableContainers(all)) return all } type sortableContainers []types.ContainerReport // Len implements sort.Interface.Len func (s sortableContainers) Len() int { return len(s) } // Less implements sort.Interface.Less func (s sortableContainers) Less(i, j int) bool { return s[i].ID() < s[j].ID() } // Swap implements sort.Interface.Swap func (s sortableContainers) Swap(i, j int) { s[i], s[j] = s[j], s[i] } ================================================ FILE: pkg/notifications/preview/data/status.go ================================================ package data import wt "github.com/containrrr/watchtower/pkg/types" type containerStatus struct { containerID wt.ContainerID oldImage wt.ImageID newImage wt.ImageID containerName string imageName string error state State } func (u *containerStatus) ID() wt.ContainerID { return u.containerID } func (u *containerStatus) Name() string { return u.containerName } func (u *containerStatus) CurrentImageID() wt.ImageID { return u.oldImage } func (u *containerStatus) LatestImageID() wt.ImageID { return u.newImage } func (u *containerStatus) ImageName() string { return u.imageName } func (u *containerStatus) Error() string { if u.error == nil { return "" } return u.error.Error() } func (u *containerStatus) State() string { return string(u.state) } ================================================ FILE: pkg/notifications/preview/tplprev.go ================================================ package preview import ( "fmt" "strings" "text/template" "github.com/containrrr/watchtower/pkg/notifications/preview/data" "github.com/containrrr/watchtower/pkg/notifications/templates" ) func Render(input string, states []data.State, loglevels []data.LogLevel) (string, error) { data := data.New() tpl, err := template.New("").Funcs(templates.Funcs).Parse(input) if err != nil { return "", fmt.Errorf("failed to parse %v", err) } for _, state := range states { data.AddFromState(state) } for _, level := range loglevels { data.AddLogEntry(level) } var buf strings.Builder err = tpl.Execute(&buf, data) if err != nil { return "", fmt.Errorf("failed to execute template: %v", err) } return buf.String(), nil } ================================================ FILE: pkg/notifications/shoutrrr.go ================================================ package notifications import ( "bytes" stdlog "log" "os" "strings" "text/template" "time" "github.com/containrrr/shoutrrr" "github.com/containrrr/shoutrrr/pkg/types" "github.com/containrrr/watchtower/pkg/notifications/templates" t "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" ) // LocalLog is a logrus logger that does not send entries as notifications var LocalLog = log.WithField("notify", "no") const ( shoutrrrType = "shoutrrr" ) type router interface { Send(message string, params *types.Params) []error } // Implements Notifier, logrus.Hook type shoutrrrTypeNotifier struct { Urls []string Router router entries []*log.Entry logLevel log.Level template *template.Template messages chan string done chan bool legacyTemplate bool params *types.Params data StaticData receiving bool delay time.Duration } // GetScheme returns the scheme part of a Shoutrrr URL func GetScheme(url string) string { schemeEnd := strings.Index(url, ":") if schemeEnd <= 0 { return "invalid" } return url[:schemeEnd] } // GetNames returns a list of notification services that has been added func (n *shoutrrrTypeNotifier) GetNames() []string { names := make([]string, len(n.Urls)) for i, u := range n.Urls { names[i] = GetScheme(u) } return names } // GetURLs returns a list of URLs for notification services that has been added func (n *shoutrrrTypeNotifier) GetURLs() []string { return n.Urls } // AddLogHook adds the notifier as a receiver of log messages and starts a go func for processing them func (n *shoutrrrTypeNotifier) AddLogHook() { if n.receiving { return } n.receiving = true log.AddHook(n) // Do the sending in a separate goroutine, so we don't block the main process. go sendNotifications(n) } func createNotifier(urls []string, level log.Level, tplString string, legacy bool, data StaticData, stdout bool, delay time.Duration) *shoutrrrTypeNotifier { tpl, err := getShoutrrrTemplate(tplString, legacy) if err != nil { log.Errorf("Could not use configured notification template: %s. Using default template", err) } var logger types.StdLogger if stdout { logger = stdlog.New(os.Stdout, ``, 0) } else { logger = stdlog.New(log.StandardLogger().WriterLevel(log.TraceLevel), "Shoutrrr: ", 0) } r, err := shoutrrr.NewSender(logger, urls...) if err != nil { log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error()) } params := &types.Params{} if data.Title != "" { params.SetTitle(data.Title) } return &shoutrrrTypeNotifier{ Urls: urls, Router: r, messages: make(chan string, 1), done: make(chan bool), logLevel: level, template: tpl, legacyTemplate: legacy, data: data, params: params, delay: delay, } } func sendNotifications(n *shoutrrrTypeNotifier) { for msg := range n.messages { time.Sleep(n.delay) errs := n.Router.Send(msg, n.params) for i, err := range errs { if err != nil { scheme := GetScheme(n.Urls[i]) // Use fmt so it doesn't trigger another notification. LocalLog.WithFields(log.Fields{ "service": scheme, "index": i, }).WithError(err).Error("Failed to send shoutrrr notification") } } } n.done <- true } func (n *shoutrrrTypeNotifier) buildMessage(data Data) (string, error) { var body bytes.Buffer var templateData interface{} = data if n.legacyTemplate { templateData = data.Entries } if err := n.template.Execute(&body, templateData); err != nil { return "", err } return body.String(), nil } func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry, report t.Report) { msg, err := n.buildMessage(Data{n.data, entries, report}) if msg == "" { // Log in go func in case we entered from Fire to avoid stalling go func() { if err != nil { LocalLog.WithError(err).Fatal("Notification template error") } else if len(n.Urls) > 1 { LocalLog.Info("Skipping notification due to empty message") } }() return } n.messages <- msg } // StartNotification begins queueing up messages to send them as a batch func (n *shoutrrrTypeNotifier) StartNotification() { if n.entries == nil { n.entries = make([]*log.Entry, 0, 10) } } // SendNotification sends the queued up messages as a notification func (n *shoutrrrTypeNotifier) SendNotification(report t.Report) { n.sendEntries(n.entries, report) n.entries = nil } // Close prevents further messages from being queued and waits until all the currently queued up messages have been sent func (n *shoutrrrTypeNotifier) Close() { close(n.messages) // Use fmt so it doesn't trigger another notification. LocalLog.Info("Waiting for the notification goroutine to finish") <-n.done } // Levels return what log levels trigger notifications func (n *shoutrrrTypeNotifier) Levels() []log.Level { return log.AllLevels[:n.logLevel+1] } // Fire is the hook that logrus calls on a new log message func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error { if entry.Data["notify"] == "no" { // Skip logging if explicitly tagged as non-notify return nil } if n.entries != nil { n.entries = append(n.entries, entry) } else { // Log output generated outside a cycle is sent immediately. n.sendEntries([]*log.Entry{entry}, nil) } return nil } func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template, err error) { tplBase := template.New("").Funcs(templates.Funcs) if builtin, found := commonTemplates[tplString]; found { log.WithField(`template`, tplString).Debug(`Using common template`) tplString = builtin } // If we succeed in getting a non-empty template configuration // try to parse the template string. if tplString != "" { tpl, err = tplBase.Parse(tplString) } // If we had an error (either from parsing the template string // or from getting the template configuration) or a // template wasn't configured (the empty template string) // fallback to using the default template. if err != nil || tplString == "" { defaultKey := `default` if legacy { defaultKey = `default-legacy` } tpl = template.Must(tplBase.Parse(commonTemplates[defaultKey])) } return } ================================================ FILE: pkg/notifications/shoutrrr_test.go ================================================ package notifications import ( "time" "github.com/containrrr/shoutrrr/pkg/types" "github.com/containrrr/watchtower/internal/actions/mocks" "github.com/containrrr/watchtower/internal/flags" s "github.com/containrrr/watchtower/pkg/session" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/onsi/gomega/gbytes" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) var allButTrace = logrus.DebugLevel var legacyMockData = Data{ Entries: []*logrus.Entry{ { Level: logrus.InfoLevel, Message: "foo Bar", }, }, } var mockDataMultipleEntries = Data{ Entries: []*logrus.Entry{ { Level: logrus.InfoLevel, Message: "The situation is under control", }, { Level: logrus.WarnLevel, Message: "All the smoke might be covering up some problems", }, { Level: logrus.ErrorLevel, Message: "Turns out everything is on fire", }, }, } var mockDataAllFresh = Data{ Entries: []*logrus.Entry{}, Report: mocks.CreateMockProgressReport(s.FreshState), } func mockDataFromStates(states ...s.State) Data { hostname := "Mock" prefix := "" return Data{ Entries: legacyMockData.Entries, Report: mocks.CreateMockProgressReport(states...), StaticData: StaticData{ Title: GetTitle(hostname, prefix), Host: hostname, }, } } var _ = Describe("Shoutrrr", func() { var logBuffer *gbytes.Buffer BeforeEach(func() { logBuffer = gbytes.NewBuffer() logrus.SetOutput(logBuffer) logrus.SetLevel(logrus.TraceLevel) logrus.SetFormatter(&logrus.TextFormatter{ DisableColors: true, DisableTimestamp: true, }) }) When("passing a common template name", func() { It("should format using that template", func() { expected := ` updt1 (mock/updt1:latest): Updated `[1:] data := mockDataFromStates(s.UpdatedState) Expect(getTemplatedResult(`porcelain.v1.summary-no-log`, false, data)).To(Equal(expected)) }) }) When("adding a log hook", func() { When("it has not been added before", func() { It("should be added to the logrus hooks", func() { level := logrus.TraceLevel hooksBefore := len(logrus.StandardLogger().Hooks[level]) shoutrrr := createNotifier([]string{}, level, "", true, StaticData{}, false, time.Second) shoutrrr.AddLogHook() hooksAfter := len(logrus.StandardLogger().Hooks[level]) Expect(hooksAfter).To(BeNumerically(">", hooksBefore)) }) }) When("it is being added a second time", func() { It("should not be added to the logrus hooks", func() { level := logrus.TraceLevel shoutrrr := createNotifier([]string{}, level, "", true, StaticData{}, false, time.Second) shoutrrr.AddLogHook() hooksBefore := len(logrus.StandardLogger().Hooks[level]) shoutrrr.AddLogHook() hooksAfter := len(logrus.StandardLogger().Hooks[level]) Expect(hooksAfter).To(Equal(hooksBefore)) }) }) }) When("using legacy templates", func() { When("no custom template is provided", func() { It("should format the messages using the default template", func() { cmd := new(cobra.Command) flags.RegisterNotificationFlags(cmd) shoutrrr := createNotifier([]string{}, logrus.TraceLevel, "", true, StaticData{}, false, time.Second) entries := []*logrus.Entry{ { Message: "foo bar", }, } s, err := shoutrrr.buildMessage(Data{Entries: entries}) Expect(err).NotTo(HaveOccurred()) Expect(s).To(Equal("foo bar\n")) }) }) When("given a valid custom template", func() { It("should format the messages using the custom template", func() { tplString := `{{range .}}{{.Level}}: {{.Message}}{{println}}{{end}}` tpl, err := getShoutrrrTemplate(tplString, true) Expect(err).ToNot(HaveOccurred()) shoutrrr := &shoutrrrTypeNotifier{ template: tpl, legacyTemplate: true, } entries := []*logrus.Entry{ { Level: logrus.InfoLevel, Message: "foo bar", }, } s, err := shoutrrr.buildMessage(Data{Entries: entries}) Expect(err).NotTo(HaveOccurred()) Expect(s).To(Equal("info: foo bar\n")) }) }) Describe("the default template", func() { When("all containers are fresh", func() { It("should return an empty string", func() { Expect(getTemplatedResult(``, true, mockDataAllFresh)).To(Equal("")) }) }) }) When("given an invalid custom template", func() { It("should format the messages using the default template", func() { invNotif, err := createNotifierWithTemplate(`{{ intentionalSyntaxError`, true) Expect(err).To(HaveOccurred()) invMsg, err := invNotif.buildMessage(legacyMockData) Expect(err).NotTo(HaveOccurred()) defNotif, err := createNotifierWithTemplate(``, true) Expect(err).ToNot(HaveOccurred()) defMsg, err := defNotif.buildMessage(legacyMockData) Expect(err).ToNot(HaveOccurred()) Expect(invMsg).To(Equal(defMsg)) }) }) When("given a template that is using ToUpper function", func() { It("should return the text in UPPER CASE", func() { tplString := `{{range .}}{{ .Message | ToUpper }}{{end}}` Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("FOO BAR")) }) }) When("given a template that is using ToLower function", func() { It("should return the text in lower case", func() { tplString := `{{range .}}{{ .Message | ToLower }}{{end}}` Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("foo bar")) }) }) When("given a template that is using Title function", func() { It("should return the text in Title Case", func() { tplString := `{{range .}}{{ .Message | Title }}{{end}}` Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("Foo Bar")) }) }) }) When("using report templates", func() { When("no custom template is provided", func() { It("should format the messages using the default template", func() { expected := `4 Scanned, 2 Updated, 1 Failed - updt1 (mock/updt1:latest): 01d110000000 updated to d0a110000000 - updt2 (mock/updt2:latest): 01d120000000 updated to d0a120000000 - frsh1 (mock/frsh1:latest): Fresh - skip1 (mock/skip1:latest): Skipped: unpossible - fail1 (mock/fail1:latest): Failed: accidentally the whole container` data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState) Expect(getTemplatedResult(``, false, data)).To(Equal(expected)) }) }) When("using a template referencing Title", func() { It("should contain the title in the output", func() { expected := `Watchtower updates on Mock` data := mockDataFromStates(s.UpdatedState) Expect(getTemplatedResult(`{{ .Title }}`, false, data)).To(Equal(expected)) }) }) When("using a template referencing Host", func() { It("should contain the hostname in the output", func() { expected := `Mock` data := mockDataFromStates(s.UpdatedState) Expect(getTemplatedResult(`{{ .Host }}`, false, data)).To(Equal(expected)) }) }) Describe("the default template", func() { When("all containers are fresh", func() { It("should return an empty string", func() { Expect(getTemplatedResult(``, false, mockDataAllFresh)).To(Equal("")) }) }) When("at least one container was updated", func() { It("should send a report", func() { expected := `1 Scanned, 1 Updated, 0 Failed - updt1 (mock/updt1:latest): 01d110000000 updated to d0a110000000` data := mockDataFromStates(s.UpdatedState) Expect(getTemplatedResult(``, false, data)).To(Equal(expected)) }) }) When("at least one container failed to update", func() { It("should send a report", func() { expected := `1 Scanned, 0 Updated, 1 Failed - fail1 (mock/fail1:latest): Failed: accidentally the whole container` data := mockDataFromStates(s.FailedState) Expect(getTemplatedResult(``, false, data)).To(Equal(expected)) }) }) When("the report is nil", func() { It("should return the logged entries", func() { expected := `The situation is under control All the smoke might be covering up some problems Turns out everything is on fire ` Expect(getTemplatedResult(``, false, mockDataMultipleEntries)).To(Equal(expected)) }) }) }) }) When("batching notifications", func() { When("no messages are queued", func() { It("should not send any notification", func() { shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{}, false, time.Duration(0)) shoutrrr.StartNotification() shoutrrr.SendNotification(nil) Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`)) }) }) When("at least one message is queued", func() { It("should send a notification", func() { shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{}, false, time.Duration(0)) shoutrrr.AddLogHook() shoutrrr.StartNotification() logrus.Info("This log message is sponsored by ContainrrrVPN") shoutrrr.SendNotification(nil) Eventually(logBuffer).Should(gbytes.Say(`Shoutrrr: This log message is sponsored by ContainrrrVPN`)) }) }) }) When("the title data field is empty", func() { It("should not have set the title param", func() { shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{ Host: "test.host", Title: "", }, false, time.Second) _, found := shoutrrr.params.Title() Expect(found).ToNot(BeTrue()) }) }) When("sending notifications", func() { It("SlowNotificationNotSent", func() { _, blockingRouter := sendNotificationsWithBlockingRouter(true) Eventually(blockingRouter.sent).Should(Not(Receive())) }) It("SlowNotificationSent", func() { shoutrrr, blockingRouter := sendNotificationsWithBlockingRouter(true) blockingRouter.unlock <- true shoutrrr.Close() Eventually(blockingRouter.sent).Should(Receive(BeTrue())) }) }) }) type blockingRouter struct { unlock chan bool sent chan bool } func (b blockingRouter) Send(_ string, _ *types.Params) []error { <-b.unlock b.sent <- true return nil } func sendNotificationsWithBlockingRouter(legacy bool) (*shoutrrrTypeNotifier, *blockingRouter) { router := &blockingRouter{ unlock: make(chan bool, 1), sent: make(chan bool, 1), } tpl, err := getShoutrrrTemplate("", legacy) Expect(err).NotTo(HaveOccurred()) shoutrrr := &shoutrrrTypeNotifier{ template: tpl, messages: make(chan string, 1), done: make(chan bool), Router: router, legacyTemplate: legacy, params: &types.Params{}, delay: time.Duration(0), } entry := &logrus.Entry{ Message: "foo bar", } go sendNotifications(shoutrrr) shoutrrr.StartNotification() _ = shoutrrr.Fire(entry) shoutrrr.SendNotification(nil) return shoutrrr, router } func createNotifierWithTemplate(tplString string, legacy bool) (*shoutrrrTypeNotifier, error) { tpl, err := getShoutrrrTemplate(tplString, legacy) return &shoutrrrTypeNotifier{ template: tpl, legacyTemplate: legacy, }, err } func getTemplatedResult(tplString string, legacy bool, data Data) (msg string) { notifier, err := createNotifierWithTemplate(tplString, legacy) ExpectWithOffset(1, err).NotTo(HaveOccurred()) msg, err = notifier.buildMessage(data) ExpectWithOffset(1, err).NotTo(HaveOccurred()) return msg } ================================================ FILE: pkg/notifications/slack.go ================================================ package notifications import ( "strings" shoutrrrDisco "github.com/containrrr/shoutrrr/pkg/services/discord" shoutrrrSlack "github.com/containrrr/shoutrrr/pkg/services/slack" t "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) const ( slackType = "slack" ) type slackTypeNotifier struct { HookURL string Username string Channel string IconEmoji string IconURL string } func newSlackNotifier(c *cobra.Command) t.ConvertibleNotifier { flags := c.Flags() hookURL, _ := flags.GetString("notification-slack-hook-url") userName, _ := flags.GetString("notification-slack-identifier") channel, _ := flags.GetString("notification-slack-channel") emoji, _ := flags.GetString("notification-slack-icon-emoji") iconURL, _ := flags.GetString("notification-slack-icon-url") n := &slackTypeNotifier{ HookURL: hookURL, Username: userName, Channel: channel, IconEmoji: emoji, IconURL: iconURL, } return n } func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) { trimmedURL := strings.TrimRight(s.HookURL, "/") trimmedURL = strings.TrimPrefix(trimmedURL, "https://") parts := strings.Split(trimmedURL, "/") if parts[0] == "discord.com" || parts[0] == "discordapp.com" { log.Debug("Detected a discord slack wrapper URL, using shoutrrr discord service") conf := &shoutrrrDisco.Config{ WebhookID: parts[len(parts)-3], Token: parts[len(parts)-2], Color: ColorInt, SplitLines: true, Username: s.Username, } if s.IconURL != "" { conf.Avatar = s.IconURL } return conf.GetURL().String(), nil } webhookToken := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1) conf := &shoutrrrSlack.Config{ BotName: s.Username, Color: ColorHex, Channel: "webhook", } if s.IconURL != "" { conf.Icon = s.IconURL } else if s.IconEmoji != "" { conf.Icon = s.IconEmoji } if err := conf.Token.SetFromProp(webhookToken); err != nil { return "", err } return conf.GetURL().String(), nil } ================================================ FILE: pkg/notifications/templates/funcs.go ================================================ package templates import ( "encoding/json" "fmt" "strings" "text/template" "golang.org/x/text/cases" "golang.org/x/text/language" ) var Funcs = template.FuncMap{ "ToUpper": strings.ToUpper, "ToLower": strings.ToLower, "ToJSON": toJSON, "Title": cases.Title(language.AmericanEnglish).String, } func toJSON(v interface{}) string { var bytes []byte var err error if bytes, err = json.MarshalIndent(v, "", " "); err != nil { return fmt.Sprintf("failed to marshal JSON in notification template: %v", err) } return string(bytes) } ================================================ FILE: pkg/registry/auth/auth.go ================================================ package auth import ( "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "github.com/containrrr/watchtower/pkg/registry/helpers" "github.com/containrrr/watchtower/pkg/types" ref "github.com/distribution/reference" "github.com/sirupsen/logrus" ) // ChallengeHeader is the HTTP Header containing challenge instructions const ChallengeHeader = "WWW-Authenticate" // GetToken fetches a token for the registry hosting the provided image func GetToken(container types.Container, registryAuth string) (string, error) { normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName()) if err != nil { return "", err } URL := GetChallengeURL(normalizedRef) logrus.WithField("URL", URL.String()).Debug("Built challenge URL") var req *http.Request if req, err = GetChallengeRequest(URL); err != nil { return "", err } client := &http.Client{} var res *http.Response if res, err = client.Do(req); err != nil { return "", err } defer res.Body.Close() v := res.Header.Get(ChallengeHeader) logrus.WithFields(logrus.Fields{ "status": res.Status, "header": v, }).Debug("Got response to challenge request") challenge := strings.ToLower(v) if strings.HasPrefix(challenge, "basic") { if registryAuth == "" { return "", fmt.Errorf("no credentials available") } return fmt.Sprintf("Basic %s", registryAuth), nil } if strings.HasPrefix(challenge, "bearer") { return GetBearerHeader(challenge, normalizedRef, registryAuth) } return "", errors.New("unsupported challenge type from registry") } // GetChallengeRequest creates a request for getting challenge instructions func GetChallengeRequest(URL url.URL) (*http.Request, error) { req, err := http.NewRequest("GET", URL.String(), nil) if err != nil { return nil, err } req.Header.Set("Accept", "*/*") req.Header.Set("User-Agent", "Watchtower (Docker)") return req, nil } // GetBearerHeader tries to fetch a bearer token from the registry based on the challenge instructions func GetBearerHeader(challenge string, imageRef ref.Named, registryAuth string) (string, error) { client := http.Client{} authURL, err := GetAuthURL(challenge, imageRef) if err != nil { return "", err } var r *http.Request if r, err = http.NewRequest("GET", authURL.String(), nil); err != nil { return "", err } if registryAuth != "" { logrus.Debug("Credentials found.") // CREDENTIAL: Uncomment to log registry credentials // logrus.Tracef("Credentials: %v", registryAuth) r.Header.Add("Authorization", fmt.Sprintf("Basic %s", registryAuth)) } else { logrus.Debug("No credentials found.") } var authResponse *http.Response if authResponse, err = client.Do(r); err != nil { return "", err } body, _ := io.ReadAll(authResponse.Body) tokenResponse := &types.TokenResponse{} err = json.Unmarshal(body, tokenResponse) if err != nil { return "", err } return fmt.Sprintf("Bearer %s", tokenResponse.Token), nil } // GetAuthURL from the instructions in the challenge func GetAuthURL(challenge string, imageRef ref.Named) (*url.URL, error) { loweredChallenge := strings.ToLower(challenge) raw := strings.TrimPrefix(loweredChallenge, "bearer") pairs := strings.Split(raw, ",") values := make(map[string]string, len(pairs)) for _, pair := range pairs { trimmed := strings.Trim(pair, " ") if key, val, ok := strings.Cut(trimmed, "="); ok { values[key] = strings.Trim(val, `"`) } } logrus.WithFields(logrus.Fields{ "realm": values["realm"], "service": values["service"], }).Debug("Checking challenge header content") if values["realm"] == "" || values["service"] == "" { return nil, fmt.Errorf("challenge header did not include all values needed to construct an auth url") } authURL, _ := url.Parse(values["realm"]) q := authURL.Query() q.Add("service", values["service"]) scopeImage := ref.Path(imageRef) scope := fmt.Sprintf("repository:%s:pull", scopeImage) logrus.WithFields(logrus.Fields{"scope": scope, "image": imageRef.Name()}).Debug("Setting scope for auth token") q.Add("scope", scope) authURL.RawQuery = q.Encode() return authURL, nil } // GetChallengeURL returns the URL to check auth requirements // for access to a given image func GetChallengeURL(imageRef ref.Named) url.URL { host, _ := helpers.GetRegistryAddress(imageRef.Name()) URL := url.URL{ Scheme: "https", Host: host, Path: "/v2/", } return URL } ================================================ FILE: pkg/registry/auth/auth_test.go ================================================ package auth_test import ( "fmt" "net/url" "os" "strings" "testing" "time" "github.com/containrrr/watchtower/internal/actions/mocks" "github.com/containrrr/watchtower/pkg/registry/auth" wtTypes "github.com/containrrr/watchtower/pkg/types" ref "github.com/distribution/reference" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestAuth(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Registry Auth Suite") } func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() { if credentials.Username == "" { return func() { Skip("Username missing. Skipping integration test") } } else if credentials.Password == "" { return func() { Skip("Password missing. Skipping integration test") } } else { return fn } } var GHCRCredentials = &wtTypes.RegistryCredentials{ Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"), Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"), } var _ = Describe("the auth module", func() { mockId := "mock-id" mockName := "mock-container" mockImage := "ghcr.io/k6io/operator:latest" mockCreated := time.Now() mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547" mockContainer := mocks.CreateMockContainerWithDigest( mockId, mockName, mockImage, mockCreated, mockDigest) Describe("GetToken", func() { It("should parse the token from the response", SkipIfCredentialsEmpty(GHCRCredentials, func() { creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password) token, err := auth.GetToken(mockContainer, creds) Expect(err).NotTo(HaveOccurred()) Expect(token).NotTo(Equal("")) }), ) }) Describe("GetAuthURL", func() { It("should create a valid auth url object based on the challenge header supplied", func() { challenge := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"` imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower") Expect(err).NotTo(HaveOccurred()) expected := &url.URL{ Host: "ghcr.io", Scheme: "https", Path: "/token", RawQuery: "scope=repository%3Acontainrrr%2Fwatchtower%3Apull&service=ghcr.io", } URL, err := auth.GetAuthURL(challenge, imageRef) Expect(err).NotTo(HaveOccurred()) Expect(URL).To(Equal(expected)) }) When("given an invalid challenge header", func() { It("should return an error", func() { challenge := `bearer realm="https://ghcr.io/token"` imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower") Expect(err).NotTo(HaveOccurred()) URL, err := auth.GetAuthURL(challenge, imageRef) Expect(err).To(HaveOccurred()) Expect(URL).To(BeNil()) }) }) When("deriving the auth scope from an image name", func() { It("should prepend official dockerhub images with \"library/\"", func() { Expect(getScopeFromImageAuthURL("registry")).To(Equal("library/registry")) Expect(getScopeFromImageAuthURL("docker.io/registry")).To(Equal("library/registry")) Expect(getScopeFromImageAuthURL("index.docker.io/registry")).To(Equal("library/registry")) }) It("should not include vanity hosts\"", func() { Expect(getScopeFromImageAuthURL("docker.io/containrrr/watchtower")).To(Equal("containrrr/watchtower")) Expect(getScopeFromImageAuthURL("index.docker.io/containrrr/watchtower")).To(Equal("containrrr/watchtower")) }) It("should not destroy three segment image names\"", func() { Expect(getScopeFromImageAuthURL("piksel/containrrr/watchtower")).To(Equal("piksel/containrrr/watchtower")) Expect(getScopeFromImageAuthURL("ghcr.io/piksel/containrrr/watchtower")).To(Equal("piksel/containrrr/watchtower")) }) It("should not prepend library/ to image names if they're not on dockerhub", func() { Expect(getScopeFromImageAuthURL("ghcr.io/watchtower")).To(Equal("watchtower")) Expect(getScopeFromImageAuthURL("ghcr.io/containrrr/watchtower")).To(Equal("containrrr/watchtower")) }) }) It("should not crash when an empty field is received", func() { input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull",` imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower") Expect(err).NotTo(HaveOccurred()) res, err := auth.GetAuthURL(input, imageRef) Expect(err).NotTo(HaveOccurred()) Expect(res).NotTo(BeNil()) }) It("should not crash when a field without a value is received", func() { input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull",valuelesskey` imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower") Expect(err).NotTo(HaveOccurred()) res, err := auth.GetAuthURL(input, imageRef) Expect(err).NotTo(HaveOccurred()) Expect(res).NotTo(BeNil()) }) }) Describe("GetChallengeURL", func() { It("should create a valid challenge url object based on the image ref supplied", func() { expected := url.URL{Host: "ghcr.io", Scheme: "https", Path: "/v2/"} imageRef, _ := ref.ParseNormalizedNamed("ghcr.io/containrrr/watchtower:latest") Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected)) }) It("should assume Docker Hub for image refs with no explicit registry", func() { expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"} imageRef, _ := ref.ParseNormalizedNamed("containrrr/watchtower:latest") Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected)) }) It("should use index.docker.io if the image ref specifies docker.io", func() { expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"} imageRef, _ := ref.ParseNormalizedNamed("docker.io/containrrr/watchtower:latest") Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected)) }) }) }) var scopeImageRegexp = MatchRegexp("^repository:[a-z0-9]+(/[a-z0-9]+)*:pull$") func getScopeFromImageAuthURL(imageName string) string { normalizedRef, _ := ref.ParseNormalizedNamed(imageName) challenge := `bearer realm="https://dummy.host/token",service="dummy.host",scope="repository:user/image:pull"` URL, _ := auth.GetAuthURL(challenge, normalizedRef) scope := URL.Query().Get("scope") Expect(scopeImageRegexp.Match(scope)).To(BeTrue()) return strings.Replace(scope[11:], ":pull", "", 1) } ================================================ FILE: pkg/registry/digest/digest.go ================================================ package digest import ( "crypto/tls" "encoding/base64" "encoding/json" "errors" "fmt" "net" "net/http" "strings" "time" "github.com/containrrr/watchtower/internal/meta" "github.com/containrrr/watchtower/pkg/registry/auth" "github.com/containrrr/watchtower/pkg/registry/manifest" "github.com/containrrr/watchtower/pkg/types" "github.com/sirupsen/logrus" ) // ContentDigestHeader is the key for the key-value pair containing the digest header const ContentDigestHeader = "Docker-Content-Digest" // CompareDigest ... func CompareDigest(container types.Container, registryAuth string) (bool, error) { if !container.HasImageInfo() { return false, errors.New("container image info missing") } var digest string registryAuth = TransformAuth(registryAuth) token, err := auth.GetToken(container, registryAuth) if err != nil { return false, err } digestURL, err := manifest.BuildManifestURL(container) if err != nil { return false, err } if digest, err = GetDigest(digestURL, token); err != nil { return false, err } logrus.WithField("remote", digest).Debug("Found a remote digest to compare with") for _, dig := range container.ImageInfo().RepoDigests { localDigest := strings.Split(dig, "@")[1] fields := logrus.Fields{"local": localDigest, "remote": digest} logrus.WithFields(fields).Debug("Comparing") if localDigest == digest { logrus.Debug("Found a match") return true, nil } } return false, nil } // TransformAuth from a base64 encoded json object to base64 encoded string func TransformAuth(registryAuth string) string { b, _ := base64.StdEncoding.DecodeString(registryAuth) credentials := &types.RegistryCredentials{} _ = json.Unmarshal(b, credentials) if credentials.Username != "" && credentials.Password != "" { ba := []byte(fmt.Sprintf("%s:%s", credentials.Username, credentials.Password)) registryAuth = base64.StdEncoding.EncodeToString(ba) } return registryAuth } // GetDigest from registry using a HEAD request to prevent rate limiting func GetDigest(url string, token string) (string, error) { tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } client := &http.Client{Transport: tr} req, _ := http.NewRequest("HEAD", url, nil) req.Header.Set("User-Agent", meta.UserAgent) if token == "" { return "", errors.New("could not fetch token") } // CREDENTIAL: Uncomment to log the request token // logrus.WithField("token", token).Trace("Setting request token") req.Header.Add("Authorization", token) req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json") req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.list.v2+json") req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v1+json") req.Header.Add("Accept", "application/vnd.oci.image.index.v1+json") logrus.WithField("url", url).Debug("Doing a HEAD request to fetch a digest") res, err := client.Do(req) if err != nil { return "", err } defer res.Body.Close() if res.StatusCode != 200 { wwwAuthHeader := res.Header.Get("www-authenticate") if wwwAuthHeader == "" { wwwAuthHeader = "not present" } return "", fmt.Errorf("registry responded to head request with %q, auth: %q", res.Status, wwwAuthHeader) } return res.Header.Get(ContentDigestHeader), nil } ================================================ FILE: pkg/registry/digest/digest_test.go ================================================ package digest_test import ( "fmt" "github.com/containrrr/watchtower/internal/actions/mocks" "github.com/containrrr/watchtower/pkg/registry/digest" wtTypes "github.com/containrrr/watchtower/pkg/types" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/onsi/gomega/ghttp" "net/http" "os" "testing" "time" ) func TestDigest(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(GinkgoT(), "Digest Suite") } var ( DockerHubCredentials = &wtTypes.RegistryCredentials{ Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_USERNAME"), Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_PASSWORD"), } GHCRCredentials = &wtTypes.RegistryCredentials{ Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"), Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"), } ) func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() { if credentials.Username == "" { return func() { Skip("Username missing. Skipping integration test") } } else if credentials.Password == "" { return func() { Skip("Password missing. Skipping integration test") } } else { return fn } } var _ = Describe("Digests", func() { mockId := "mock-id" mockName := "mock-container" mockImage := "ghcr.io/k6io/operator:latest" mockCreated := time.Now() mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547" mockContainer := mocks.CreateMockContainerWithDigest( mockId, mockName, mockImage, mockCreated, mockDigest) mockContainerNoImage := mocks.CreateMockContainerWithImageInfoP(mockId, mockName, mockImage, mockCreated, nil) When("a digest comparison is done", func() { It("should return true if digests match", SkipIfCredentialsEmpty(GHCRCredentials, func() { creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password) matches, err := digest.CompareDigest(mockContainer, creds) Expect(err).NotTo(HaveOccurred()) Expect(matches).To(Equal(true)) }), ) It("should return false if digests differ", func() { }) It("should return an error if the registry isn't available", func() { }) It("should return an error when container contains no image info", func() { matches, err := digest.CompareDigest(mockContainerNoImage, `user:pass`) Expect(err).To(HaveOccurred()) Expect(matches).To(Equal(false)) }) }) When("using different registries", func() { It("should work with DockerHub", SkipIfCredentialsEmpty(DockerHubCredentials, func() { fmt.Println(DockerHubCredentials != nil) // to avoid crying linters }), ) It("should work with GitHub Container Registry", SkipIfCredentialsEmpty(GHCRCredentials, func() { fmt.Println(GHCRCredentials != nil) // to avoid crying linters }), ) }) When("sending a HEAD request", func() { var server *ghttp.Server BeforeEach(func() { server = ghttp.NewServer() }) AfterEach(func() { server.Close() }) It("should use a custom user-agent", func() { server.AppendHandlers( ghttp.CombineHandlers( ghttp.VerifyHeader(http.Header{ "User-Agent": []string{"Watchtower/v0.0.0-unknown"}, }), ghttp.RespondWith(http.StatusOK, "", http.Header{ digest.ContentDigestHeader: []string{ mockDigest, }, }), ), ) dig, err := digest.GetDigest(server.URL(), "token") Expect(server.ReceivedRequests()).Should(HaveLen(1)) Expect(err).NotTo(HaveOccurred()) Expect(dig).To(Equal(mockDigest)) }) }) }) ================================================ FILE: pkg/registry/helpers/helpers.go ================================================ package helpers import ( "github.com/distribution/reference" ) // domains for Docker Hub, the default registry const ( DefaultRegistryDomain = "docker.io" DefaultRegistryHost = "index.docker.io" LegacyDefaultRegistryDomain = "index.docker.io" ) // GetRegistryAddress parses an image name // and returns the address of the specified registry func GetRegistryAddress(imageRef string) (string, error) { normalizedRef, err := reference.ParseNormalizedNamed(imageRef) if err != nil { return "", err } address := reference.Domain(normalizedRef) if address == DefaultRegistryDomain { address = DefaultRegistryHost } return address, nil } ================================================ FILE: pkg/registry/helpers/helpers_test.go ================================================ package helpers import ( "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestHelpers(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Helper Suite") } var _ = Describe("the helpers", func() { Describe("GetRegistryAddress", func() { It("should return error if passed empty string", func() { _, err := GetRegistryAddress("") Expect(err).To(HaveOccurred()) }) It("should return index.docker.io for image refs with no explicit registry", func() { Expect(GetRegistryAddress("watchtower")).To(Equal("index.docker.io")) Expect(GetRegistryAddress("containrrr/watchtower")).To(Equal("index.docker.io")) }) It("should return index.docker.io for image refs with docker.io domain", func() { Expect(GetRegistryAddress("docker.io/watchtower")).To(Equal("index.docker.io")) Expect(GetRegistryAddress("docker.io/containrrr/watchtower")).To(Equal("index.docker.io")) }) It("should return the host if passed an image name containing a local host", func() { Expect(GetRegistryAddress("henk:80/watchtower")).To(Equal("henk:80")) Expect(GetRegistryAddress("localhost/watchtower")).To(Equal("localhost")) }) It("should return the server address if passed a fully qualified image name", func() { Expect(GetRegistryAddress("github.com/containrrr/config")).To(Equal("github.com")) }) }) }) ================================================ FILE: pkg/registry/manifest/manifest.go ================================================ package manifest import ( "errors" "fmt" url2 "net/url" "github.com/containrrr/watchtower/pkg/registry/helpers" "github.com/containrrr/watchtower/pkg/types" ref "github.com/distribution/reference" "github.com/sirupsen/logrus" ) // BuildManifestURL from raw image data func BuildManifestURL(container types.Container) (string, error) { normalizedRef, err := ref.ParseDockerRef(container.ImageName()) if err != nil { return "", err } normalizedTaggedRef, isTagged := normalizedRef.(ref.NamedTagged) if !isTagged { return "", errors.New("Parsed container image ref has no tag: " + normalizedRef.String()) } host, _ := helpers.GetRegistryAddress(normalizedTaggedRef.Name()) img, tag := ref.Path(normalizedTaggedRef), normalizedTaggedRef.Tag() logrus.WithFields(logrus.Fields{ "image": img, "tag": tag, "normalized": normalizedTaggedRef.Name(), "host": host, }).Debug("Parsing image ref") if err != nil { return "", err } url := url2.URL{ Scheme: "https", Host: host, Path: fmt.Sprintf("/v2/%s/manifests/%s", img, tag), } return url.String(), nil } ================================================ FILE: pkg/registry/manifest/manifest_test.go ================================================ package manifest_test import ( "testing" "time" "github.com/containrrr/watchtower/internal/actions/mocks" "github.com/containrrr/watchtower/pkg/registry/manifest" apiTypes "github.com/docker/docker/api/types" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestManifest(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Manifest Suite") } var _ = Describe("the manifest module", func() { Describe("BuildManifestURL", func() { It("should return a valid url given a fully qualified image", func() { imageRef := "ghcr.io/containrrr/watchtower:mytag" expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/mytag" URL, err := buildMockContainerManifestURL(imageRef) Expect(err).NotTo(HaveOccurred()) Expect(URL).To(Equal(expected)) }) It("should assume Docker Hub for image refs with no explicit registry", func() { imageRef := "containrrr/watchtower:latest" expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest" URL, err := buildMockContainerManifestURL(imageRef) Expect(err).NotTo(HaveOccurred()) Expect(URL).To(Equal(expected)) }) It("should assume latest for image refs with no explicit tag", func() { imageRef := "containrrr/watchtower" expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest" URL, err := buildMockContainerManifestURL(imageRef) Expect(err).NotTo(HaveOccurred()) Expect(URL).To(Equal(expected)) }) It("should not prepend library/ for single-part container names in registries other than Docker Hub", func() { imageRef := "docker-registry.domain/imagename:latest" expected := "https://docker-registry.domain/v2/imagename/manifests/latest" URL, err := buildMockContainerManifestURL(imageRef) Expect(err).NotTo(HaveOccurred()) Expect(URL).To(Equal(expected)) }) It("should throw an error on pinned images", func() { imageRef := "docker-registry.domain/imagename@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7" URL, err := buildMockContainerManifestURL(imageRef) Expect(err).To(HaveOccurred()) Expect(URL).To(BeEmpty()) }) }) }) func buildMockContainerManifestURL(imageRef string) (string, error) { imageInfo := apiTypes.ImageInspect{ RepoTags: []string{ imageRef, }, } mockID := "mock-id" mockName := "mock-container" mockCreated := time.Now() mock := mocks.CreateMockContainerWithImageInfo(mockID, mockName, imageRef, mockCreated, imageInfo) return manifest.BuildManifestURL(mock) } ================================================ FILE: pkg/registry/registry.go ================================================ package registry import ( "github.com/containrrr/watchtower/pkg/registry/helpers" watchtowerTypes "github.com/containrrr/watchtower/pkg/types" ref "github.com/distribution/reference" "github.com/docker/docker/api/types" log "github.com/sirupsen/logrus" ) // GetPullOptions creates a struct with all options needed for pulling images from a registry func GetPullOptions(imageName string) (types.ImagePullOptions, error) { auth, err := EncodedAuth(imageName) log.Debugf("Got image name: %s", imageName) if err != nil { return types.ImagePullOptions{}, err } if auth == "" { return types.ImagePullOptions{}, nil } // CREDENTIAL: Uncomment to log docker config auth // log.Tracef("Got auth value: %s", auth) return types.ImagePullOptions{ RegistryAuth: auth, PrivilegeFunc: DefaultAuthHandler, }, nil } // DefaultAuthHandler will be invoked if an AuthConfig is rejected // It could be used to return a new value for the "X-Registry-Auth" authentication header, // but there's no point trying again with the same value as used in AuthConfig func DefaultAuthHandler() (string, error) { log.Debug("Authentication request was rejected. Trying again without authentication") return "", nil } // WarnOnAPIConsumption will return true if the registry is known-expected // to respond well to HTTP HEAD in checking the container digest -- or if there // are problems parsing the container hostname. // Will return false if behavior for container is unknown. func WarnOnAPIConsumption(container watchtowerTypes.Container) bool { normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName()) if err != nil { return true } containerHost, err := helpers.GetRegistryAddress(normalizedRef.Name()) if err != nil { return true } if containerHost == helpers.DefaultRegistryHost || containerHost == "ghcr.io" { return true } return false } ================================================ FILE: pkg/registry/registry_suite_test.go ================================================ package registry_test import ( "github.com/sirupsen/logrus" "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestRegistry(t *testing.T) { RegisterFailHandler(Fail) logrus.SetOutput(GinkgoWriter) RunSpecs(t, "Registry Suite") } ================================================ FILE: pkg/registry/registry_test.go ================================================ package registry_test import ( "github.com/containrrr/watchtower/internal/actions/mocks" unit "github.com/containrrr/watchtower/pkg/registry" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "time" ) var _ = Describe("Registry", func() { Describe("WarnOnAPIConsumption", func() { When("Given a container with an image from ghcr.io", func() { It("should want to warn", func() { Expect(testContainerWithImage("ghcr.io/containrrr/watchtower")).To(BeTrue()) }) }) When("Given a container with an image implicitly from dockerhub", func() { It("should want to warn", func() { Expect(testContainerWithImage("docker:latest")).To(BeTrue()) }) }) When("Given a container with an image explicitly from dockerhub", func() { It("should want to warn", func() { Expect(testContainerWithImage("index.docker.io/docker:latest")).To(BeTrue()) Expect(testContainerWithImage("docker.io/docker:latest")).To(BeTrue()) }) }) When("Given a container with an image from some other registry", func() { It("should not want to warn", func() { Expect(testContainerWithImage("docker.fsf.org/docker:latest")).To(BeFalse()) Expect(testContainerWithImage("altavista.com/docker:latest")).To(BeFalse()) Expect(testContainerWithImage("gitlab.com/docker:latest")).To(BeFalse()) }) }) }) }) func testContainerWithImage(imageName string) bool { container := mocks.CreateMockContainer("", "", imageName, time.Now()) return unit.WarnOnAPIConsumption(container) } ================================================ FILE: pkg/registry/trust.go ================================================ package registry import ( "encoding/base64" "encoding/json" "errors" "os" "github.com/containrrr/watchtower/pkg/registry/helpers" cliconfig "github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/credentials" "github.com/docker/cli/cli/config/types" log "github.com/sirupsen/logrus" ) // EncodedAuth returns an encoded auth config for the given registry // loaded from environment variables or docker config // as available in that order func EncodedAuth(ref string) (string, error) { auth, err := EncodedEnvAuth() if err != nil { auth, err = EncodedConfigAuth(ref) } return auth, err } // EncodedEnvAuth returns an encoded auth config for the given registry // loaded from environment variables // Returns an error if authentication environment variables have not been set func EncodedEnvAuth() (string, error) { username := os.Getenv("REPO_USER") password := os.Getenv("REPO_PASS") if username != "" && password != "" { auth := types.AuthConfig{ Username: username, Password: password, } log.Debugf("Loaded auth credentials for registry user %s from environment", auth.Username) // CREDENTIAL: Uncomment to log REPO_PASS environment variable // log.Tracef("Using auth password %s", auth.Password) return EncodeAuth(auth) } return "", errors.New("registry auth environment variables (REPO_USER, REPO_PASS) not set") } // EncodedConfigAuth returns an encoded auth config for the given registry // loaded from the docker config // Returns an empty string if credentials cannot be found for the referenced server // The docker config must be mounted on the container func EncodedConfigAuth(imageRef string) (string, error) { server, err := helpers.GetRegistryAddress(imageRef) if err != nil { log.Errorf("Could not get registry from image ref %s", imageRef) return "", err } configDir := os.Getenv("DOCKER_CONFIG") if configDir == "" { configDir = "/" } configFile, err := cliconfig.Load(configDir) if err != nil { log.Errorf("Unable to find default config file: %s", err) return "", err } credStore := CredentialsStore(*configFile) auth, _ := credStore.Get(server) // returns (types.AuthConfig{}) if server not in credStore if auth == (types.AuthConfig{}) { log.WithField("config_file", configFile.Filename).Debugf("No credentials for %s found", server) return "", nil } log.Debugf("Loaded auth credentials for user %s, on registry %s, from file %s", auth.Username, server, configFile.Filename) // CREDENTIAL: Uncomment to log docker config password // log.Tracef("Using auth password %s", auth.Password) return EncodeAuth(auth) } // CredentialsStore returns a new credentials store based // on the settings provided in the configuration file. func CredentialsStore(configFile configfile.ConfigFile) credentials.Store { if configFile.CredentialsStore != "" { return credentials.NewNativeStore(&configFile, configFile.CredentialsStore) } return credentials.NewFileStore(&configFile) } // EncodeAuth Base64 encode an AuthConfig struct for transmission over HTTP func EncodeAuth(authConfig types.AuthConfig) (string, error) { buf, err := json.Marshal(authConfig) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(buf), nil } ================================================ FILE: pkg/registry/trust_test.go ================================================ package registry import ( "os" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) var _ = Describe("Registry credential helpers", func() { Describe("EncodedAuth", func() { It("should return repo credentials from env when set", func() { var err error expected := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0=" err = os.Setenv("REPO_USER", "containrrr-user") Expect(err).NotTo(HaveOccurred()) err = os.Setenv("REPO_PASS", "containrrr-pass") Expect(err).NotTo(HaveOccurred()) config, err := EncodedEnvAuth() Expect(config).To(Equal(expected)) Expect(err).NotTo(HaveOccurred()) }) }) Describe("EncodedEnvAuth", func() { It("should return an error if repo envs are unset", func() { _ = os.Unsetenv("REPO_USER") _ = os.Unsetenv("REPO_PASS") _, err := EncodedEnvAuth() Expect(err).To(HaveOccurred()) }) }) Describe("EncodedConfigAuth", func() { It("should return an error if file is not present", func() { var err error err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail") Expect(err).NotTo(HaveOccurred()) _, err = EncodedConfigAuth("") Expect(err).To(HaveOccurred()) }) }) }) ================================================ FILE: pkg/session/container_status.go ================================================ package session import wt "github.com/containrrr/watchtower/pkg/types" // State indicates what the current state is of the container type State int // State enum values const ( // UnknownState is only used to represent an uninitialized State value UnknownState State = iota SkippedState ScannedState UpdatedState FailedState FreshState StaleState ) // ContainerStatus contains the container state during a session type ContainerStatus struct { containerID wt.ContainerID oldImage wt.ImageID newImage wt.ImageID containerName string imageName string error state State } // ID returns the container ID func (u *ContainerStatus) ID() wt.ContainerID { return u.containerID } // Name returns the container name func (u *ContainerStatus) Name() string { return u.containerName } // CurrentImageID returns the image ID that the container used when the session started func (u *ContainerStatus) CurrentImageID() wt.ImageID { return u.oldImage } // LatestImageID returns the newest image ID found during the session func (u *ContainerStatus) LatestImageID() wt.ImageID { return u.newImage } // ImageName returns the name:tag that the container uses func (u *ContainerStatus) ImageName() string { return u.imageName } // Error returns the error (if any) that was encountered for the container during a session func (u *ContainerStatus) Error() string { if u.error == nil { return "" } return u.error.Error() } // State returns the current State that the container is in func (u *ContainerStatus) State() string { switch u.state { case SkippedState: return "Skipped" case ScannedState: return "Scanned" case UpdatedState: return "Updated" case FailedState: return "Failed" case FreshState: return "Fresh" case StaleState: return "Stale" default: return "Unknown" } } ================================================ FILE: pkg/session/progress.go ================================================ package session import ( "github.com/containrrr/watchtower/pkg/types" ) // Progress contains the current session container status type Progress map[types.ContainerID]*ContainerStatus // UpdateFromContainer sets various status fields from their corresponding container equivalents func UpdateFromContainer(cont types.Container, newImage types.ImageID, state State) *ContainerStatus { return &ContainerStatus{ containerID: cont.ID(), containerName: cont.Name(), imageName: cont.ImageName(), oldImage: cont.SafeImageID(), newImage: newImage, state: state, } } // AddSkipped adds a container to the Progress with the state set as skipped func (m Progress) AddSkipped(cont types.Container, err error) { update := UpdateFromContainer(cont, cont.SafeImageID(), SkippedState) update.error = err m.Add(update) } // AddScanned adds a container to the Progress with the state set as scanned func (m Progress) AddScanned(cont types.Container, newImage types.ImageID) { m.Add(UpdateFromContainer(cont, newImage, ScannedState)) } // UpdateFailed updates the containers passed, setting their state as failed with the supplied error func (m Progress) UpdateFailed(failures map[types.ContainerID]error) { for id, err := range failures { update := m[id] update.error = err update.state = FailedState } } // Add a container to the map using container ID as the key func (m Progress) Add(update *ContainerStatus) { m[update.containerID] = update } // MarkForUpdate marks the container identified by containerID for update func (m Progress) MarkForUpdate(containerID types.ContainerID) { m[containerID].state = UpdatedState } // Report creates a new Report from a Progress instance func (m Progress) Report() types.Report { return NewReport(m) } ================================================ FILE: pkg/session/report.go ================================================ package session import ( "sort" "github.com/containrrr/watchtower/pkg/types" ) type report struct { scanned []types.ContainerReport updated []types.ContainerReport failed []types.ContainerReport skipped []types.ContainerReport stale []types.ContainerReport fresh []types.ContainerReport } func (r *report) Scanned() []types.ContainerReport { return r.scanned } func (r *report) Updated() []types.ContainerReport { return r.updated } func (r *report) Failed() []types.ContainerReport { return r.failed } func (r *report) Skipped() []types.ContainerReport { return r.skipped } func (r *report) Stale() []types.ContainerReport { return r.stale } func (r *report) Fresh() []types.ContainerReport { return r.fresh } func (r *report) All() []types.ContainerReport { allLen := len(r.scanned) + len(r.updated) + len(r.failed) + len(r.skipped) + len(r.stale) + len(r.fresh) all := make([]types.ContainerReport, 0, allLen) presentIds := map[types.ContainerID][]string{} appendUnique := func(reports []types.ContainerReport) { for _, cr := range reports { if _, found := presentIds[cr.ID()]; found { continue } all = append(all, cr) presentIds[cr.ID()] = nil } } appendUnique(r.updated) appendUnique(r.failed) appendUnique(r.skipped) appendUnique(r.stale) appendUnique(r.fresh) appendUnique(r.scanned) sort.Sort(sortableContainers(all)) return all } // NewReport creates a types.Report from the supplied Progress func NewReport(progress Progress) types.Report { report := &report{ scanned: []types.ContainerReport{}, updated: []types.ContainerReport{}, failed: []types.ContainerReport{}, skipped: []types.ContainerReport{}, stale: []types.ContainerReport{}, fresh: []types.ContainerReport{}, } for _, update := range progress { if update.state == SkippedState { report.skipped = append(report.skipped, update) continue } report.scanned = append(report.scanned, update) if update.newImage == update.oldImage { update.state = FreshState report.fresh = append(report.fresh, update) continue } switch update.state { case UpdatedState: report.updated = append(report.updated, update) case FailedState: report.failed = append(report.failed, update) default: update.state = StaleState report.stale = append(report.stale, update) } } sort.Sort(sortableContainers(report.scanned)) sort.Sort(sortableContainers(report.updated)) sort.Sort(sortableContainers(report.failed)) sort.Sort(sortableContainers(report.skipped)) sort.Sort(sortableContainers(report.stale)) sort.Sort(sortableContainers(report.fresh)) return report } type sortableContainers []types.ContainerReport // Len implements sort.Interface.Len func (s sortableContainers) Len() int { return len(s) } // Less implements sort.Interface.Less func (s sortableContainers) Less(i, j int) bool { return s[i].ID() < s[j].ID() } // Swap implements sort.Interface.Swap func (s sortableContainers) Swap(i, j int) { s[i], s[j] = s[j], s[i] } ================================================ FILE: pkg/sorter/sort.go ================================================ package sorter import ( "fmt" "time" "github.com/containrrr/watchtower/pkg/types" ) // ByCreated allows a list of Container structs to be sorted by the container's // created date. type ByCreated []types.Container func (c ByCreated) Len() int { return len(c) } func (c ByCreated) Swap(i, j int) { c[i], c[j] = c[j], c[i] } // Less will compare two elements (identified by index) in the Container // list by created-date. func (c ByCreated) Less(i, j int) bool { t1, err := time.Parse(time.RFC3339Nano, c[i].ContainerInfo().Created) if err != nil { t1 = time.Now() } t2, _ := time.Parse(time.RFC3339Nano, c[j].ContainerInfo().Created) if err != nil { t1 = time.Now() } return t1.Before(t2) } // SortByDependencies will sort the list of containers taking into account any // links between containers. Container with no outgoing links will be sorted to // the front of the list while containers with links will be sorted after all // of their dependencies. This sort order ensures that linked containers can // be started in the correct order. func SortByDependencies(containers []types.Container) ([]types.Container, error) { sorter := dependencySorter{} return sorter.Sort(containers) } type dependencySorter struct { unvisited []types.Container marked map[string]bool sorted []types.Container } func (ds *dependencySorter) Sort(containers []types.Container) ([]types.Container, error) { ds.unvisited = containers ds.marked = map[string]bool{} for len(ds.unvisited) > 0 { if err := ds.visit(ds.unvisited[0]); err != nil { return nil, err } } return ds.sorted, nil } func (ds *dependencySorter) visit(c types.Container) error { if _, ok := ds.marked[c.Name()]; ok { return fmt.Errorf("circular reference to %s", c.Name()) } // Mark any visited node so that circular references can be detected ds.marked[c.Name()] = true defer delete(ds.marked, c.Name()) // Recursively visit links for _, linkName := range c.Links() { if linkedContainer := ds.findUnvisited(linkName); linkedContainer != nil { if err := ds.visit(*linkedContainer); err != nil { return err } } } // Move container from unvisited to sorted ds.removeUnvisited(c) ds.sorted = append(ds.sorted, c) return nil } func (ds *dependencySorter) findUnvisited(name string) *types.Container { for _, c := range ds.unvisited { if c.Name() == name { return &c } } return nil } func (ds *dependencySorter) removeUnvisited(c types.Container) { var idx int for i := range ds.unvisited { if ds.unvisited[i].Name() == c.Name() { idx = i break } } ds.unvisited = append(ds.unvisited[0:idx], ds.unvisited[idx+1:]...) } ================================================ FILE: pkg/types/container.go ================================================ package types import ( "strings" "github.com/docker/docker/api/types" dc "github.com/docker/docker/api/types/container" ) // ImageID is a hash string representing a container image type ImageID string // ContainerID is a hash string representing a container instance type ContainerID string // ShortID returns the 12-character (hex) short version of an image ID hash, removing any "sha256:" prefix if present func (id ImageID) ShortID() (short string) { return shortID(string(id)) } // ShortID returns the 12-character (hex) short version of a container ID hash, removing any "sha256:" prefix if present func (id ContainerID) ShortID() (short string) { return shortID(string(id)) } func shortID(longID string) string { prefixSep := strings.IndexRune(longID, ':') offset := 0 length := 12 if prefixSep >= 0 { if longID[0:prefixSep] == "sha256" { offset = prefixSep + 1 } else { length += prefixSep + 1 } } if len(longID) >= offset+length { return longID[offset : offset+length] } return longID } // Container is a docker container running an image type Container interface { ContainerInfo() *types.ContainerJSON ID() ContainerID IsRunning() bool Name() string ImageID() ImageID SafeImageID() ImageID ImageName() string Enabled() (bool, bool) IsMonitorOnly(UpdateParams) bool Scope() (string, bool) Links() []string ToRestart() bool IsWatchtower() bool StopSignal() string HasImageInfo() bool ImageInfo() *types.ImageInspect GetLifecyclePreCheckCommand() string GetLifecyclePostCheckCommand() string GetLifecyclePreUpdateCommand() string GetLifecyclePostUpdateCommand() string VerifyConfiguration() error SetStale(bool) IsStale() bool IsNoPull(UpdateParams) bool SetLinkedToRestarting(bool) IsLinkedToRestarting() bool PreUpdateTimeout() int PostUpdateTimeout() int IsRestarting() bool GetCreateConfig() *dc.Config GetCreateHostConfig() *dc.HostConfig } ================================================ FILE: pkg/types/convertible_notifier.go ================================================ package types import ( "time" "github.com/spf13/cobra" ) // ConvertibleNotifier is a notifier capable of creating a shoutrrr URL type ConvertibleNotifier interface { GetURL(c *cobra.Command) (string, error) } // DelayNotifier is a notifier that might need to be delayed before sending notifications type DelayNotifier interface { GetDelay() time.Duration } ================================================ FILE: pkg/types/filter.go ================================================ package types // A Filter is a prototype for a function that can be used to filter the // results from a call to the ListContainers() method on the Client. type Filter func(FilterableContainer) bool ================================================ FILE: pkg/types/filterable_container.go ================================================ package types // A FilterableContainer is the interface which is used to filter // containers. type FilterableContainer interface { Name() string IsWatchtower() bool Enabled() (bool, bool) Scope() (string, bool) ImageName() string } ================================================ FILE: pkg/types/notifier.go ================================================ package types // Notifier is the interface that all notification services have in common type Notifier interface { StartNotification() SendNotification(Report) AddLogHook() GetNames() []string GetURLs() []string Close() } ================================================ FILE: pkg/types/registry_credentials.go ================================================ package types // RegistryCredentials is a credential pair used for basic auth type RegistryCredentials struct { Username string Password string // usually a token rather than an actual password } ================================================ FILE: pkg/types/report.go ================================================ package types // Report contains reports for all the containers processed during a session type Report interface { Scanned() []ContainerReport Updated() []ContainerReport Failed() []ContainerReport Skipped() []ContainerReport Stale() []ContainerReport Fresh() []ContainerReport All() []ContainerReport } // ContainerReport represents a container that was included in watchtower session type ContainerReport interface { ID() ContainerID Name() string CurrentImageID() ImageID LatestImageID() ImageID ImageName() string Error() string State() string } ================================================ FILE: pkg/types/token_response.go ================================================ package types // TokenResponse is returned by the registry on successful authentication type TokenResponse struct { Token string `json:"token"` } ================================================ FILE: pkg/types/update_params.go ================================================ package types import ( "time" ) // UpdateParams contains all different options available to alter the behavior of the Update func type UpdateParams struct { Filter Filter Cleanup bool NoRestart bool Timeout time.Duration MonitorOnly bool NoPull bool LifecycleHooks bool RollingRestart bool LabelPrecedence bool } ================================================ FILE: prometheus/prometheus.yml ================================================ scrape_configs: - job_name: watchtower scrape_interval: 5s metrics_path: /v1/metrics bearer_token: demotoken static_configs: - targets: - 'watchtower:8080' ================================================ FILE: scripts/build-tplprev.sh ================================================ #!/bin/bash cd $(git rev-parse --show-toplevel) cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./docs/assets/ GOARCH=wasm GOOS=js go build -o ./docs/assets/tplprev.wasm ./tplprev ================================================ FILE: scripts/codecov.sh ================================================ #!/usr/bin/env bash go test -v -coverprofile coverage.out -covermode atomic ./... # Requires CODECOV_TOKEN to be set bash <(curl -s https://codecov.io/bash) ================================================ FILE: scripts/contnet-tests.sh ================================================ #!/usr/bin/env bash set -e function exit_env_err() { >&2 echo "Required environment variable not set: $1" exit 1 } if [ -z "$VPN_SERVICE_PROVIDER" ]; then exit_env_err "VPN_SERVICE_PROVIDER"; fi if [ -z "$OPENVPN_USER" ]; then exit_env_err "OPENVPN_USER"; fi if [ -z "$OPENVPN_PASSWORD" ]; then exit_env_err "OPENVPN_PASSWORD"; fi # if [ -z "$SERVER_COUNTRIES" ]; then exit_env_err "SERVER_COUNTRIES"; fi export SERVER_COUNTRIES=${SERVER_COUNTRIES:"Sweden"} REPO_ROOT="$(git rev-parse --show-toplevel)" COMPOSE_FILE="$REPO_ROOT/dockerfiles/container-networking/docker-compose.yml" DEFAULT_WATCHTOWER="$REPO_ROOT/watchtower" WATCHTOWER="$*" WATCHTOWER=${WATCHTOWER:-$DEFAULT_WATCHTOWER} echo "repo root path is $REPO_ROOT" echo "watchtower path is $WATCHTOWER" echo "compose file path is $COMPOSE_FILE" echo; echo "=== Forcing network container producer update..." echo "Pull previous version of gluetun..." docker pull qmcgaw/gluetun:v3.34.3 echo "Fake new version of gluetun by retagging v3.34.4 as v3.35.0..." docker tag qmcgaw/gluetun:v3.34.3 qmcgaw/gluetun:v3.35.0 echo; echo "=== Creating containers..." docker compose -p "wt-contnet" -f "$COMPOSE_FILE" up -d echo; echo "=== Running watchtower" $WATCHTOWER --run-once echo; echo "=== Removing containers..." docker compose -p "wt-contnet" -f "$COMPOSE_FILE" down ================================================ FILE: scripts/dependency-test.sh ================================================ #!/usr/bin/env bash # Simulates a container that will always be updated, checking whether it shuts down it's dependencies correctly. # Note that this test does not verify the results in any way set -e SCRIPT_ROOT=$(dirname "$(readlink -m "$(type -p "$0")")") source "$SCRIPT_ROOT/docker-util.sh" DepArgs="" if [ -z "$1" ] || [ "$1" == "depends-on" ]; then DepArgs="--label com.centurylinklabs.watchtower.depends-on=parent" elif [ "$1" == "linked" ]; then DepArgs="--link parent" else DepArgs=$1 fi WatchArgs="${*:2}" if [ -z "$WatchArgs" ]; then WatchArgs="--debug" fi try-remove-container parent try-remove-container depending REPO=$(registry-host) create-dummy-image deptest/parent create-dummy-image deptest/depending echo "" echo -en "Starting \e[94mparent\e[0m container... " CmdParent="docker run -d -p 9090 --name parent $REPO/deptest/parent" $CmdParent PARENT_REV_BEFORE=$(query-rev parent) PARENT_START_BEFORE=$(container-started parent) echo -e "Rev: \e[92m$PARENT_REV_BEFORE\e[0m" echo -e "Started: \e[96m$PARENT_START_BEFORE\e[0m" echo -e "Command: \e[37m$CmdParent\e[0m" echo "" echo -en "Starting \e[94mdepending\e[0m container... " CmdDepend="docker run -d -p 9090 --name depending $DepArgs $REPO/deptest/depending" $CmdDepend DEPEND_REV_BEFORE=$(query-rev depending) DEPEND_START_BEFORE=$(container-started depending) echo -e "Rev: \e[92m$DEPEND_REV_BEFORE\e[0m" echo -e "Started: \e[96m$DEPEND_START_BEFORE\e[0m" echo -e "Command: \e[37m$CmdDepend\e[0m" echo -e "" create-dummy-image deptest/parent echo -e "\nRunning watchtower..." if [ -z "$WATCHTOWER_TAG" ]; then ## Windows support: #export DOCKER_HOST=tcp://localhost:2375 #export CLICOLOR=1 go run . --run-once $WatchArgs else docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower:"$WATCHTOWER_TAG" --run-once $WatchArgs fi echo -e "\nSession results:" PARENT_REV_AFTER=$(query-rev parent) PARENT_START_AFTER=$(container-started parent) echo -en " Parent image: \e[95m$PARENT_REV_BEFORE\e[0m => \e[94m$PARENT_REV_AFTER\e[0m " if [ "$PARENT_REV_AFTER" == "$PARENT_REV_BEFORE" ]; then echo -e "(\e[91mSame\e[0m)" else echo -e "(\e[92mUpdated\e[0m)" fi echo -en " Parent container: \e[95m$PARENT_START_BEFORE\e[0m => \e[94m$PARENT_START_AFTER\e[0m " if [ "$PARENT_START_AFTER" == "$PARENT_START_BEFORE" ]; then echo -e "(\e[91mSame\e[0m)" else echo -e "(\e[92mRestarted\e[0m)" fi echo "" DEPEND_REV_AFTER=$(query-rev depending) DEPEND_START_AFTER=$(container-started depending) echo -en " Depend image: \e[95m$DEPEND_REV_BEFORE\e[0m => \e[94m$DEPEND_REV_AFTER\e[0m " if [ "$DEPEND_REV_BEFORE" == "$DEPEND_REV_AFTER" ]; then echo -e "(\e[92mSame\e[0m)" else echo -e "(\e[91mUpdated\e[0m)" fi echo -en " Depend container: \e[95m$DEPEND_START_BEFORE\e[0m => \e[94m$DEPEND_START_AFTER\e[0m " if [ "$DEPEND_START_BEFORE" == "$DEPEND_START_AFTER" ]; then echo -e "(\e[91mSame\e[0m)" else echo -e "(\e[92mRestarted\e[0m)" fi echo "" ================================================ FILE: scripts/docker-util.sh ================================================ #!/usr/bin/env bash # This file is meant to be sourced into other scripts and contain some utility functions for docker e2e testing CONTAINER_PREFIX=${CONTAINER_PREFIX:-du} function get-port() { Container=$1 Port=$2 if [ -z "$Container" ]; then echo "CONTAINER missing" 1>&2 return 1 fi if [ -z "$Port" ]; then echo "PORT missing" 1>&2 return 1 fi Query=".[].NetworkSettings.Ports[\"$Port/tcp\"] | .[0].HostPort" docker container inspect "$Container" | jq -r "$Query" } function start-registry() { local Name="$CONTAINER_PREFIX-registry" echo -en "Starting \e[94m$Name\e[0m container... " local Port="${1:-5000}" docker run -d -p 5000:"$Port" --restart=unless-stopped --name "$Name" registry:2 } function stop-registry() { try-remove-container "$CONTAINER_PREFIX-registry" } function registry-host() { echo "localhost:$(get-port "$CONTAINER_PREFIX"-registry 5000)" } function try-remove-container() { echo -en "Looking for container \e[95m$1\e[0m... " local Found Found=$(container-id "$1") if [ -n "$Found" ]; then echo "$Found" echo -n " Stopping... " docker stop "$1" echo -n " Removing... " docker rm "$1" else echo "Not found" fi } function create-dummy-image() { if [ -z "$1" ]; then echo "TAG missing" return 1 fi local Tag="$1" local Repo Repo="$(registry-host)" local Revision=${2:-$(("$(date +%s)" - "$(date --date='2021-10-21' +%s)"))} echo -e "Creating new image \e[95m$Tag\e[0m revision: \e[94m$Revision\e[0m" local BuildDir="/tmp/docker-dummy-$Tag-$Revision" mkdir -p "$BuildDir" cat > "$BuildDir/Dockerfile" << END FROM alpine RUN echo "Tag: $Tag" RUN echo "Revision: $Revision" ENTRYPOINT ["nc", "-lk", "-v", "-l", "-p", "9090", "-e", "echo", "-e", "HTTP/1.1 200 OK\n\n$Tag $Revision"] END docker build -t "$Repo/$Tag:latest" -t "$Repo/$Tag:r$Revision" "$BuildDir" echo -e "Pushing images...\e[93m" docker push -q "$Repo/$Tag:latest" docker push -q "$Repo/$Tag:r$Revision" echo -en "\e[0m" rm -r "$BuildDir" } function query-rev() { local Name=$1 if [ -z "$Name" ]; then echo "NAME missing" return 1 fi curl -s "localhost:$(get-port "$Name" 9090)" } function latest-image-rev() { local Tag=$1 if [ -z "$Tag" ]; then echo "TAG missing" return 1 fi local ID ID=$(docker image ls "$(registry-host)"/"$Tag":latest -q) docker image inspect "$ID" | jq -r '.[].RepoTags | .[]' | grep -v latest } function container-id() { local Name=$1 if [ -z "$Name" ]; then echo "NAME missing" return 1 fi docker container ls -f name="$Name" -q } function container-started() { local Name=$1 if [ -z "$Name" ]; then echo "NAME missing" return 1 fi docker container inspect "$Name" | jq -r .[].State.StartedAt } function container-exists() { local Name=$1 if [ -z "$Name" ]; then echo "NAME missing" return 1 fi docker container inspect "$Name" 1> /dev/null 2> /dev/null } function registry-exists() { container-exists "$CONTAINER_PREFIX-registry" } function create-container() { local container_name=$1 if [ -z "$container_name" ]; then echo "NAME missing" return 1 fi local image_name="${2:-$container_name}" echo -en "Creating \e[94m$container_name\e[0m container... " local result result=$(docker run -d --name "$container_name" "$(registry-host)/$image_name" 2>&1) if [ "${#result}" -eq 64 ]; then echo -e "\e[92m${result:0:12}\e[0m" return 0 else echo -e "\e[91mFailed!\n\e[97m$result\e[0m" return 1 fi } function remove-images() { local image_name=$1 if [ -z "$image_name" ]; then echo "NAME missing" return 1 fi local images mapfile -t images < <(docker images -q "$image_name" | uniq) if [ -n "${images[*]}" ]; then docker image rm "${images[@]}" else echo "No images matched \"$image_name\"" fi } function remove-repo-images() { local image_name=$1 if [ -z "$image_name" ]; then echo "NAME missing" return 1 fi remove-images "$(registry-host)/images/$image_name" } ================================================ FILE: scripts/du-cli.sh ================================================ #!/usr/bin/env bash SCRIPT_ROOT=$(dirname "$(readlink -m "$(type -p "$0")")") source "$SCRIPT_ROOT/docker-util.sh" case $1 in registry | reg | r) case $2 in start) start-registry ;; stop) stop-registry ;; host) registry-host ;; *) echo "Unknown registry action \"$2\"" ;; esac ;; image | img | i) case $2 in rev) create-dummy-image "${@:3:2}" ;; latest) latest-image-rev "$3" ;; rm) remove-repo-images "$3" ;; *) echo "Unknown image action \"$2\"" ;; esac ;; container | cnt | c) case $2 in query) query-rev "$3" ;; rm) try-remove-container "$3" ;; id) container-id "$3" ;; started) container-started "$3" ;; create) create-container "${@:3:2}" ;; create-stale) if [ -z "$3" ]; then echo "NAME missing" exit 1 fi if ! registry-exists; then echo "Registry container missing! Creating..." start-registry || exit 1 fi image_name="images/$3" container_name=$3 $0 image rev "$image_name" || exit 1 $0 container create "$container_name" "$image_name" || exit 1 $0 image rev "$image_name" || exit 1 ;; *) echo "Unknown container action \"$2\"" ;; esac ;; *) echo "Unknown keyword \"$1\"" ;; esac ================================================ FILE: scripts/lifecycle-tests.sh ================================================ #!/usr/bin/env bash set -e IMAGE=server CONTAINER=server LINKED_IMAGE=linked LINKED_CONTAINER=linked WATCHTOWER_INTERVAL=2 function remove_container { docker kill $1 >> /dev/null || true && docker rm -v $1 >> /dev/null || true } function cleanup { # Do cleanup on exit or error echo "Final cleanup" sleep 2 remove_container $CONTAINER remove_container $LINKED_CONTAINER pkill -9 -f watchtower >> /dev/null || true } trap cleanup EXIT DEFAULT_WATCHTOWER="$(dirname "${BASH_SOURCE[0]}")/../watchtower" WATCHTOWER=$1 WATCHTOWER=${WATCHTOWER:-$DEFAULT_WATCHTOWER} echo "watchtower path is $WATCHTOWER" ################################################################################## ##### PREPARATION ################################################################ ################################################################################## # Create Dockerfile template DOCKERFILE=$(cat << EOF FROM node:alpine LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="cat /opt/test/value.txt" LABEL com.centurylinklabs.watchtower.lifecycle.post-update="echo image > /opt/test/value.txt" ENV IMAGE_TIMESTAMP=TIMESTAMP WORKDIR /opt/test ENTRYPOINT ["/usr/local/bin/node", "/opt/test/server.js"] EXPOSE 8888 RUN mkdir -p /opt/test && echo "default" > /opt/test/value.txt COPY server.js /opt/test/server.js EOF ) # Create temporary directory to build docker image TMP_DIR="/tmp/watchtower-commands-test" mkdir -p $TMP_DIR # Create simple http server cat > $TMP_DIR/server.js << EOF const http = require("http"); const fs = require("fs"); http.createServer(function(request, response) { const fileContent = fs.readFileSync("/opt/test/value.txt"); response.writeHead(200, {"Content-Type": "text/plain"}); response.write(fileContent); response.end(); }).listen(8888, () => { console.log('server is listening on 8888'); }); EOF function builddocker { TIMESTAMP=$(date +%s) echo "Building image $TIMESTAMP" echo "${DOCKERFILE/TIMESTAMP/$TIMESTAMP}" > $TMP_DIR/Dockerfile docker build $TMP_DIR -t $IMAGE >> /dev/null } # Start watchtower echo "Starting watchtower" $WATCHTOWER -i $WATCHTOWER_INTERVAL --no-pull --stop-timeout 2s --enable-lifecycle-hooks $CONTAINER $LINKED_CONTAINER & sleep 3 echo "#################################################################" echo "##### TEST CASE 1: Execute commands from base image" echo "#################################################################" # Build base image builddocker # Run container docker run -d -p 0.0.0.0:8888:8888 --name $CONTAINER $IMAGE:latest >> /dev/null sleep 1 echo "Container $CONTAINER is running" # Test default value RESP=$(curl -s http://localhost:8888) if [ $RESP != "default" ]; then echo "Default value of container response is invalid" 1>&2 exit 1 fi # Build updated image to trigger watchtower update builddocker WAIT_AMOUNT=$(($WATCHTOWER_INTERVAL * 3)) echo "Wait for $WAIT_AMOUNT seconds" sleep $WAIT_AMOUNT # Test value after post-update-command RESP=$(curl -s http://localhost:8888) if [[ $RESP != "image" ]]; then echo "Value of container response is invalid. Expected: image. Actual: $RESP" exit 1 fi remove_container $CONTAINER echo "#################################################################" echo "##### TEST CASE 2: Execute commands from container and base image" echo "#################################################################" # Build base image builddocker # Run container docker run -d -p 0.0.0.0:8888:8888 \ --label=com.centurylinklabs.watchtower.lifecycle.post-update="echo container > /opt/test/value.txt" \ --name $CONTAINER $IMAGE:latest >> /dev/null sleep 1 echo "Container $CONTAINER is running" # Test default value RESP=$(curl -s http://localhost:8888) if [ $RESP != "default" ]; then echo "Default value of container response is invalid" 1>&2 exit 1 fi # Build updated image to trigger watchtower update builddocker WAIT_AMOUNT=$(($WATCHTOWER_INTERVAL * 3)) echo "Wait for $WAIT_AMOUNT seconds" sleep $WAIT_AMOUNT # Test value after post-update-command RESP=$(curl -s http://localhost:8888) if [[ $RESP != "container" ]]; then echo "Value of container response is invalid. Expected: container. Actual: $RESP" exit 1 fi remove_container $CONTAINER echo "#################################################################" echo "##### TEST CASE 3: Execute commands with a linked container" echo "#################################################################" # Tag the current image to keep a version for the linked container docker tag $IMAGE:latest $LINKED_IMAGE:latest # Build base image builddocker # Run container docker run -d -p 0.0.0.0:8888:8888 \ --label=com.centurylinklabs.watchtower.lifecycle.post-update="echo container > /opt/test/value.txt" \ --name $CONTAINER $IMAGE:latest >> /dev/null docker run -d -p 0.0.0.0:8989:8888 \ --label=com.centurylinklabs.watchtower.lifecycle.post-update="echo container > /opt/test/value.txt" \ --link $CONTAINER \ --name $LINKED_CONTAINER $LINKED_IMAGE:latest >> /dev/null sleep 1 echo "Container $CONTAINER and $LINKED_CONTAINER are running" # Test default value RESP=$(curl -s http://localhost:8888) if [ $RESP != "default" ]; then echo "Default value of container response is invalid" 1>&2 exit 1 fi # Test default value for linked container RESP=$(curl -s http://localhost:8989) if [ $RESP != "default" ]; then echo "Default value of linked container response is invalid" 1>&2 exit 1 fi # Build updated image to trigger watchtower update builddocker WAIT_AMOUNT=$(($WATCHTOWER_INTERVAL * 3)) echo "Wait for $WAIT_AMOUNT seconds" sleep $WAIT_AMOUNT # Test value after post-update-command RESP=$(curl -s http://localhost:8888) if [[ $RESP != "container" ]]; then echo "Value of container response is invalid. Expected: container. Actual: $RESP" exit 1 fi # Test that linked container did not execute pre/post-update-command RESP=$(curl -s http://localhost:8989) if [[ $RESP != "default" ]]; then echo "Value of linked container response is invalid. Expected: default. Actual: $RESP" exit 1 fi ================================================ FILE: tplprev/main.go ================================================ //go:build !wasm package main import ( "flag" "fmt" "os" "github.com/containrrr/watchtower/internal/meta" "github.com/containrrr/watchtower/pkg/notifications/preview" "github.com/containrrr/watchtower/pkg/notifications/preview/data" ) func main() { fmt.Fprintf(os.Stderr, "watchtower/tplprev %v\n\n", meta.Version) var states string var entries string flag.StringVar(&states, "states", "cccuuueeekkktttfff", "sCanned, Updated, failEd, sKipped, sTale, Fresh") flag.StringVar(&entries, "entries", "ewwiiidddd", "Fatal,Error,Warn,Info,Debug,Trace") flag.Parse() if len(flag.Args()) < 1 { fmt.Fprintln(os.Stderr, "Missing required argument TEMPLATE") flag.Usage() os.Exit(1) return } input, err := os.ReadFile(flag.Arg(0)) if err != nil { fmt.Fprintf(os.Stderr, "Failed to read template file %q: %v\n", flag.Arg(0), err) os.Exit(1) return } result, err := preview.Render(string(input), data.StatesFromString(states), data.LevelsFromString(entries)) if err != nil { fmt.Fprintf(os.Stderr, "Failed to read template file %q: %v\n", flag.Arg(0), err) os.Exit(1) return } fmt.Println(result) } ================================================ FILE: tplprev/main_wasm.go ================================================ //go:build wasm package main import ( "fmt" "github.com/containrrr/watchtower/internal/meta" "github.com/containrrr/watchtower/pkg/notifications/preview" "github.com/containrrr/watchtower/pkg/notifications/preview/data" "syscall/js" ) func main() { fmt.Println("watchtower/tplprev v" + meta.Version) js.Global().Set("WATCHTOWER", js.ValueOf(map[string]any{ "tplprev": js.FuncOf(jsTplPrev), })) <-make(chan bool) } func jsTplPrev(this js.Value, args []js.Value) any { if len(args) < 3 { return "Requires 3 arguments passed" } input := args[0].String() statesArg := args[1] var states []data.State if statesArg.Type() == js.TypeString { states = data.StatesFromString(statesArg.String()) } else { for i := 0; i < statesArg.Length(); i++ { state := data.State(statesArg.Index(i).String()) states = append(states, state) } } levelsArg := args[2] var levels []data.LogLevel if levelsArg.Type() == js.TypeString { levels = data.LevelsFromString(statesArg.String()) } else { for i := 0; i < levelsArg.Length(); i++ { level := data.LogLevel(levelsArg.Index(i).String()) levels = append(levels, level) } } result, err := preview.Render(input, states, levels) if err != nil { return "Error: " + err.Error() } return result }