├── .gitattributes ├── .github ├── CODEOWNERS ├── CONTRIBUTING.md ├── commitlint.config.js ├── dependabot.yml └── workflows │ ├── CI&CD.yml │ ├── Create-GitHub-release.yml │ ├── Generate-TOC.yml │ └── Lint-PR-name.yml ├── .gitignore ├── .golangci.yml ├── 0-tools.go ├── Dockerfile ├── LICENSE ├── README.md ├── env.go ├── examples ├── json │ └── json-example └── nginx │ ├── Dockerfile │ └── default.tmpl ├── exec.go ├── exec_linux.go ├── exec_other.go ├── flag.go ├── go.mod ├── go.sum ├── ini.go ├── init_test.go ├── main.go ├── main_test.go ├── scripts ├── cover ├── release └── test ├── tail.go ├── template.go ├── testdata ├── env1.ini ├── env2.ini ├── secret.hdr ├── src1.tmpl └── src2 │ ├── abc │ └── subdir │ └── func ├── tls.go └── wait.go /.gitattributes: -------------------------------------------------------------------------------- 1 | # /name - apply (* doesn't match /) to file "name" beginning in project root 2 | # na/me - apply (* doesn't match /) to file "na/me" anywhere 3 | # name - apply (* do match /) to file "name" anywhere 4 | # name/** - apply … to dir … 5 | # **/name - apply (* doesn't match /) to file "name" in any dir including project root 6 | # na/**/me - apply (* doesn't match /) to file "na/me", "na/*/me", "na/*/*/me", … 7 | go.sum binary 8 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @powerman 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | ## Pull Requests 4 | 5 | * User-visible changes **must** be documented in README. 6 | * New/modified code **should** be covered by tests. 7 | * **Must** pass linter and existing tests (CI will report failure). 8 | * Review by code owner is **required** before merge (controlled by GitHub). 9 | -------------------------------------------------------------------------------- /.github/commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | commit-message: 8 | prefix: 'chore(ci)' 9 | - package-ecosystem: 'gomod' 10 | directory: '/' 11 | schedule: 12 | interval: 'daily' 13 | commit-message: 14 | prefix: 'chore(deps)' 15 | open-pull-requests-limit: 10 16 | - package-ecosystem: 'docker' 17 | directory: '/' 18 | schedule: 19 | interval: 'daily' 20 | commit-message: 21 | prefix: 'chore(deps)' 22 | -------------------------------------------------------------------------------- /.github/workflows/CI&CD.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | push: 5 | tags: [v*] 6 | branches: [master] 7 | pull_request: 8 | branches: [master] 9 | 10 | env: 11 | GO_VERSION: '1.23.2' # Also in Dockerfile. 12 | 13 | jobs: 14 | 15 | test: 16 | runs-on: 'ubuntu-latest' 17 | timeout-minutes: 30 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ env.GO_VERSION }} 24 | cache: false 25 | 26 | - uses: actions/cache@v4 27 | id: cache-go-with-tools 28 | with: 29 | path: | 30 | ~/go/pkg/mod 31 | ~/.cache/go-build 32 | ~/.cache/golangci-lint 33 | .buildcache 34 | key: v1-go-with-tools-${{ runner.os }}-${{ env.GO_VERSION }}-${{ hashFiles('0-tools.go') }}-${{ hashFiles('go.sum') }} 35 | restore-keys: | 36 | v1-go-with-tools-${{ runner.os }}-${{ env.GO_VERSION }}-${{ hashFiles('0-tools.go') }}- 37 | v1-go-with-tools-${{ runner.os }}-${{ env.GO_VERSION }}- 38 | 39 | - run: scripts/test 40 | 41 | - name: Report code coverage 42 | env: 43 | COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 44 | if: env.COVERALLS_TOKEN 45 | run: |- 46 | scripts/cover 47 | .buildcache/bin/goveralls -coverprofile=.buildcache/cover.out -service=GitHub 48 | 49 | build-and-release: 50 | needs: test 51 | runs-on: 'ubuntu-latest' 52 | timeout-minutes: 30 53 | if: github.event_name == 'push' 54 | steps: 55 | - uses: actions/checkout@v4 56 | 57 | - uses: actions/setup-go@v5 58 | with: 59 | go-version: ${{ env.GO_VERSION }} 60 | cache: false 61 | 62 | - name: Turnstyle 63 | uses: softprops/turnstyle@v2 64 | with: 65 | poll-interval-seconds: 3 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | 69 | - uses: actions/cache@v4 70 | id: cache-go-with-tools 71 | with: 72 | path: | 73 | ~/go/pkg/mod 74 | ~/.cache/go-build 75 | ~/.cache/golangci-lint 76 | .buildcache 77 | key: v1-go-with-tools-${{ runner.os }}-${{ env.GO_VERSION }}-${{ hashFiles('0-tools.go') }}-${{ hashFiles('go.sum') }} 78 | restore-keys: | 79 | v1-go-with-tools-${{ runner.os }}-${{ env.GO_VERSION }}-${{ hashFiles('0-tools.go') }}- 80 | v1-go-with-tools-${{ runner.os }}-${{ env.GO_VERSION }}- 81 | 82 | # Add support for more platforms with QEMU (optional) 83 | # https://github.com/docker/setup-qemu-action 84 | - name: Set up QEMU 85 | uses: docker/setup-qemu-action@v3 86 | 87 | - name: Set up Docker Buildx 88 | uses: docker/setup-buildx-action@v3 89 | 90 | - name: Upload to DockerHub Container Registry 91 | run: | 92 | IMAGE_NAME="${{ secrets.CR_USER }}/$(basename ${GITHUB_REPOSITORY,,})" 93 | PLATFORMS=( 94 | "linux/386" 95 | "linux/amd64" 96 | "linux/arm/v6" 97 | "linux/arm/v7" 98 | "linux/arm64/v8" 99 | "linux/ppc64le" 100 | "linux/s390x" 101 | ) 102 | 103 | docker login -u '${{ secrets.CR_USER }}' -p '${{ secrets.CR_PAT }}' 104 | 105 | TAGS=() 106 | if echo "$GITHUB_REF" | grep -qE '^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$'; then 107 | PATCH_VERSION="${GITHUB_REF/refs\/tags\/v}" 108 | MINOR_VERSION="$(echo "${PATCH_VERSION}" | cut -d "." -f 1,2)" 109 | MAJOR_VERSION="$(echo "${PATCH_VERSION}" | cut -d "." -f 1)" 110 | TAGS+=( 111 | "${IMAGE_NAME}:latest" 112 | "${IMAGE_NAME}:${PATCH_VERSION}" 113 | "${IMAGE_NAME}:${MINOR_VERSION}" 114 | "${IMAGE_NAME}:${MAJOR_VERSION}" 115 | ) 116 | fi 117 | 118 | if [ "${#TAGS}" -ne 0 ]; then 119 | docker buildx build \ 120 | --platform "$(IFS=, ; echo "${PLATFORMS[*]}")" \ 121 | $(echo "${TAGS[@]/#/--tag }") \ 122 | --push \ 123 | . 124 | fi 125 | 126 | - run: echo -e "$GPG_KEY" | gpg --import 127 | if: github.ref_type == 'tag' 128 | env: 129 | GPG_KEY: ${{ secrets.GPG_KEY }} 130 | 131 | - run: scripts/release 132 | if: github.ref_type == 'tag' 133 | env: 134 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 135 | -------------------------------------------------------------------------------- /.github/workflows/Create-GitHub-release.yml: -------------------------------------------------------------------------------- 1 | name: Create GitHub release 2 | 3 | on: 4 | push: 5 | tags: [v*] 6 | 7 | jobs: 8 | 9 | create-release: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 3 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Generate changelog 17 | id: changelog 18 | uses: metcalfc/changelog-generator@v4.3.1 19 | with: 20 | myToken: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | - name: Create release 23 | uses: actions/create-release@v1 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | tag_name: ${{ github.ref }} 28 | release_name: ${{ github.ref }} 29 | body: ${{ steps.changelog.outputs.changelog }} 30 | draft: false 31 | prerelease: false 32 | -------------------------------------------------------------------------------- /.github/workflows/Generate-TOC.yml: -------------------------------------------------------------------------------- 1 | name: Generate TOC 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | types: [opened, synchronize, reopened, closed] 9 | 10 | jobs: 11 | 12 | generate-TOC: 13 | if: github.event.pull_request.head.user.id == github.event.pull_request.base.user.id 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 3 16 | steps: 17 | - name: Turnstyle 18 | uses: softprops/turnstyle@v2 19 | with: 20 | poll-interval-seconds: 3 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | - uses: technote-space/toc-generator@v4 25 | -------------------------------------------------------------------------------- /.github/workflows/Lint-PR-name.yml: -------------------------------------------------------------------------------- 1 | name: Lint PR name 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | types: [opened, edited, synchronize, reopened] 7 | 8 | jobs: 9 | 10 | lint-PR-name: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 3 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install dependencies 17 | run: npm install @commitlint/config-conventional@16.0.0 18 | 19 | - uses: JulienKode/pull-request-name-linter-action@v0.5.0 20 | with: 21 | configuration-path: '.github/commitlint.config.js' 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # /name - exclude path (* doesn't match /) to file/dir "name" beginning in project root 2 | # na/me - exclude path (* doesn't match /) to file/dir "na/me" anywhere 3 | # name - exclude path (* do match /) to file/dir "name" anywhere 4 | # name/ - exclude path … to dir … 5 | # **/name - exclude path (* doesn't match /) to file/dir "name" in any dir including project root 6 | # na/**/me - exclude path (* doesn't match /) to file/dir "na/me", "na/*/me", "na/*/*/me", … 7 | # !name - include previously excluded path … 8 | /.buildcache/ 9 | /bin/ 10 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # Origin: https://github.com/powerman/golangci-lint-strict version 1.61.0 2 | run: 3 | modules-download-mode: readonly 4 | output: 5 | sort-results: true 6 | sort-order: 7 | - linter 8 | - severity 9 | - file # Filepath, line, and column. 10 | linters-settings: 11 | decorder: 12 | disable-init-func-first-check: false # `init` funcs have to be declared before all other functions. 13 | depguard: 14 | rules: 15 | main: 16 | deny: 17 | - pkg: github.com/prometheus/common/log 18 | desc: Should be replaced by standard lib log/slog package 19 | - pkg: github.com/sirupsen/logrus 20 | desc: Should be replaced by standard lib log/slog package 21 | - pkg: github.com/go-errors/errors 22 | desc: Should be replaced by standard lib errors package 23 | - pkg: github.com/pkg/errors 24 | desc: Should be replaced by standard lib errors package 25 | - pkg: github.com/prometheus/client_golang/prometheus/promauto 26 | desc: Not allowed because it uses global variables 27 | - pkg: github.com/golang/protobuf 28 | desc: Should be replaced by google.golang.org/protobuf package 29 | dupl: 30 | threshold: 100 # Tokens. 31 | errcheck: 32 | exclude-functions: 33 | - encoding/json.Marshal # Required because of errchkjson.check-error-free-encoding. 34 | - encoding/json.MarshalIndent # Required because of errchkjson.check-error-free-encoding. 35 | errchkjson: 36 | check-error-free-encoding: true 37 | report-no-exported: true # Encoded struct must have exported fields. 38 | exhaustive: 39 | check: 40 | - switch 41 | - map 42 | explicit-exhaustive-map: true # Only check maps with "//exhaustive:enforce" comment. 43 | exhaustruct: 44 | include: 45 | - ^$ # Only check structs which domain.tld/package/name.structname match this regexp. 46 | forbidigo: 47 | forbid: 48 | - ^print(ln)?$ 49 | exclude-godoc-examples: false 50 | analyze-types: true 51 | funlen: 52 | ignore-comments: true 53 | gci: 54 | sections: 55 | - standard # Standard section: captures all standard packages. 56 | - default # Default section: contains all imports that could not be matched to another section type. 57 | - localmodule # Local module section: contains all local packages. This section is not present unless explicitly enabled. 58 | gocognit: 59 | min-complexity: 20 60 | goconst: 61 | ignore-tests: true 62 | gocritic: 63 | enable-all: true 64 | disabled-checks: 65 | - exposedSyncMutex # Questionable. 66 | - hugeParam # Premature optimization. 67 | - paramTypeCombine # Questionable. 68 | - switchTrue # Questionable. 69 | - todoCommentWithoutDetail # Questionable. 70 | - yodaStyleExpr # Questionable. 71 | settings: 72 | captLocal: 73 | paramsOnly: false # Do not restrict checker to params only. 74 | ruleguard: 75 | failOn: all 76 | truncateCmp: 77 | skipArchDependent: false # Do not skip int/uint/uintptr types. 78 | underef: 79 | skipRecvDeref: false 80 | unnamedResult: 81 | checkExported: true 82 | godot: 83 | exclude: 84 | - :$ # Allow line followed by details in next line(s). 85 | - '^\s*- ' # Allow line with a list item. 86 | godox: 87 | keywords: 88 | - BUG # Marks issues that should be moved to issue tracker before merging. 89 | - FIXME # Marks issues that should be resolved before merging. 90 | - DEBUG # Marks temporary code that should be removed before merging. 91 | gofmt: 92 | rewrite-rules: 93 | - pattern: interface{} 94 | replacement: any 95 | - pattern: a[b:len(a)] 96 | replacement: a[b:] 97 | gomodguard: 98 | blocked: 99 | versions: 100 | - github.com/cenkalti/backoff: 101 | version: < 4.0.0 102 | reason: use actual version 103 | gosec: 104 | excludes: 105 | - G104 # Audit errors not checked 106 | exclude-generated: true 107 | config: 108 | global: 109 | audit: true 110 | govet: 111 | enable-all: true 112 | disable: 113 | - fieldalignment 114 | settings: 115 | shadow: 116 | strict: true 117 | grouper: 118 | import-require-single-import: true # Use a single 'import' declaration. 119 | importas: 120 | alias: 121 | - pkg: net/url 122 | alias: urlpkg 123 | loggercheck: 124 | require-string-key: true # Logging keys must be inlined constant strings. 125 | no-printf-like: true 126 | misspell: 127 | mode: restricted # Check only comments. 128 | nestif: 129 | min-complexity: 4 130 | nolintlint: 131 | require-explanation: true # Disable linters this way: //nolint:first,second // Reason here. 132 | require-specific: true # Do not allow //nolint without specific linter name(s). 133 | paralleltest: 134 | ignore-missing: true # Do not require `t.Parallel()` everywhere. 135 | ignore-missing-subtests: true # Do not require `t.Parallel()` in all subtests. 136 | reassign: 137 | patterns: 138 | - .* # Check all global variables. 139 | revive: 140 | rules: 141 | - name: add-constant 142 | disabled: true # Duplicates goconst and mnd linters. 143 | - name: argument-limit 144 | disabled: true # Questionable. 145 | - name: atomic 146 | - name: banned-characters 147 | arguments: [] # [ "Ω","Σ","σ", "7" ] 148 | - name: bare-return 149 | disabled: true # Questionable (in some cases bare return improves readability). 150 | - name: blank-imports 151 | - name: bool-literal-in-expr 152 | - name: call-to-gc 153 | - name: cognitive-complexity 154 | disabled: true # Duplicates gocognit linter. 155 | - name: comment-spacings 156 | arguments: 157 | - nolint # Allow //nolint without a space. 158 | - name: comments-density 159 | disabled: true # Questionable. 160 | - name: confusing-naming 161 | disabled: true # Questionable (valid use case: Method() as a thin wrapper for method()). 162 | - name: confusing-results 163 | - name: constant-logical-expr 164 | - name: context-as-argument 165 | - name: context-keys-type 166 | - name: cyclomatic 167 | disabled: true # Duplicates cyclop and gocyclo linters. 168 | - name: datarace 169 | - name: deep-exit 170 | - name: defer 171 | - name: dot-imports 172 | - name: duplicated-imports 173 | - name: early-return 174 | - name: empty-block 175 | disabled: true # https://github.com/mgechev/revive/issues/386 176 | - name: empty-lines 177 | - name: enforce-map-style 178 | arguments: 179 | - make # Use `make(map[A]B)` instead of literal `map[A]B{}`. 180 | - name: enforce-repeated-arg-type-style 181 | disabled: true # Questionable (short form for similar args and full otherwise may improve readability). 182 | - name: enforce-slice-style 183 | disabled: true # Questionable (sometimes we need a nil slice, sometimes not nil). 184 | - name: error-naming 185 | - name: error-return 186 | - name: error-strings 187 | - name: errorf 188 | - name: exported 189 | - name: file-header 190 | - name: flag-parameter 191 | - name: function-length 192 | disabled: true # Duplicates funlen linter. 193 | - name: function-result-limit 194 | disabled: true # Questionable. 195 | - name: get-return 196 | - name: identical-branches 197 | - name: if-return 198 | - name: import-alias-naming 199 | - name: import-shadowing 200 | - name: imports-blocklist 201 | - name: increment-decrement 202 | - name: indent-error-flow 203 | - name: line-length-limit 204 | disabled: true # Duplicates lll linter. 205 | - name: max-control-nesting 206 | - name: max-public-structs 207 | disabled: true # Questionable. 208 | - name: modifies-parameter 209 | - name: modifies-value-receiver 210 | - name: nested-structs 211 | disabled: true # Questionable (useful in tests, may worth enabling for non-tests). 212 | - name: optimize-operands-order 213 | - name: package-comments 214 | - name: range 215 | - name: range-val-address 216 | - name: range-val-in-closure 217 | - name: receiver-naming 218 | - name: redefines-builtin-id 219 | - name: redundant-import-alias 220 | - name: string-format 221 | arguments: 222 | - - fmt.Errorf[0] 223 | - /(^|[^\.!?])$/ 224 | - must not end in punctuation 225 | - - panic 226 | - /^[^\n]*$/ 227 | - must not contain line breaks 228 | - name: string-of-int 229 | - name: struct-tag 230 | - name: superfluous-else 231 | - name: time-equal 232 | - name: time-naming 233 | - name: unchecked-type-assertion 234 | disabled: true # Duplicates errcheck and forcetypeassert linters. 235 | - name: unconditional-recursion 236 | - name: unexported-naming 237 | - name: unexported-return 238 | - name: unhandled-error 239 | disabled: true # Duplicates errcheck linter. 240 | - name: unnecessary-stmt 241 | - name: unreachable-code 242 | - name: unused-parameter 243 | - name: unused-receiver 244 | - name: use-any 245 | - name: useless-break 246 | - name: var-declaration 247 | - name: var-naming 248 | - name: waitgroup-by-value 249 | rowserrcheck: 250 | packages: 251 | - github.com/jmoiron/sqlx 252 | - github.com/powerman/sqlxx 253 | sloglint: 254 | context: scope 255 | static-msg: true 256 | key-naming-case: snake 257 | forbidden-keys: 258 | - time # Used by standard slog.JSONHandler or slog.TextHandler. 259 | - level # Used by standard slog.JSONHandler or slog.TextHandler. 260 | - msg # Used by standard slog.JSONHandler or slog.TextHandler. 261 | - source # Used by standard slog.JSONHandler or slog.TextHandler. 262 | tagalign: 263 | order: 264 | - json 265 | - yaml 266 | - yml 267 | - toml 268 | - env 269 | - mod 270 | - mapstructure 271 | - binding 272 | - validate 273 | strict: true 274 | tagliatelle: 275 | case: 276 | use-field-name: true 277 | rules: 278 | json: snake 279 | yaml: kebab 280 | xml: camel 281 | toml: camel 282 | bson: camel 283 | avro: snake 284 | mapstructure: kebab 285 | envconfig: upperSnake 286 | testifylint: 287 | enable-all: true 288 | testpackage: 289 | skip-regexp: .*_internal_test\.go 290 | thelper: 291 | test: 292 | name: false # Allow *testing.T param to have any name, not only `t`. 293 | usestdlibvars: 294 | time-weekday: true 295 | time-month: true 296 | time-layout: true 297 | crypto-hash: true 298 | default-rpc-path: true 299 | sql-isolation-level: true 300 | tls-signature-scheme: true 301 | linters: 302 | enable-all: true 303 | disable: 304 | - containedctx # Questionable. 305 | - contextcheck # Questionable. 306 | - cyclop # Prefer gocognit. 307 | - dogsled # Questionable (assignment to how many blank identifiers is not okay?). 308 | - dupl 309 | - execinquery # Deprecated. 310 | - exportloopref # Deprecated. 311 | - forcetypeassert # Questionable (often we actually want panic). 312 | - gocyclo # Prefer gocognit. 313 | - gomnd # Deprecated. 314 | - interfacebloat # Questionable. 315 | - ireturn # Questionable (is returning unexported types better?). 316 | - lll # Questionable (sometimes long lines improve readability). 317 | - nlreturn # Questionable (often no blank line before return improve readability). 318 | - nonamedreturns # Questionable (named return act as a documentation). 319 | - perfsprint # Questionable (force performance over readability and sometimes safety). 320 | - varnamelen 321 | - wrapcheck # Questionable (see https://github.com/tomarrell/wrapcheck/issues/1). 322 | - wsl # Questionable (too much style differences, hard to consider). 323 | issues: 324 | exclude: 325 | - declaration of "(log|err|ctx)" shadows 326 | - 'missing cases in switch of type \S+: \S+_UNSPECIFIED$' 327 | exclude-rules: 328 | - path: _test\.go|testing(_.*)?\.go 329 | linters: 330 | - bodyclose 331 | - dupl 332 | - errcheck 333 | - forcetypeassert 334 | - funlen 335 | - gochecknoglobals 336 | - gochecknoinits 337 | - gocognit 338 | - goconst 339 | - gosec 340 | - maintidx 341 | - reassign 342 | - source: const # Define global const-vars like: var SomeGlobal = []int{42} // Const. 343 | linters: 344 | - gochecknoglobals 345 | - path: _test\.go|testing(_.*)?\.go 346 | text: (unnamedResult|exitAfterDefer|rangeValCopy|unnecessaryBlock) 347 | linters: 348 | - gocritic 349 | - path: _test\.go 350 | text: '"t" shadows' 351 | linters: 352 | - govet 353 | - path: ^(.*/)?embed.go$ 354 | linters: 355 | - gochecknoglobals 356 | exclude-use-default: false # Disable default exclude patterns. 357 | exclude-files: 358 | - \.[\w-]+\.go$ # Use this pattern to name auto-generated files. 359 | max-issues-per-linter: 0 360 | max-same-issues: 0 361 | severity: 362 | default-severity: error 363 | -------------------------------------------------------------------------------- /0-tools.go: -------------------------------------------------------------------------------- 1 | //go:build generate 2 | 3 | // NOTE: Prefix 0- in this file's name ensures that `go generate ./...` processes it first. 4 | 5 | //go:generate mkdir -p .buildcache/bin 6 | //go:generate -command GOINSTALL env "GOBIN=$PWD/.buildcache/bin" go install 7 | //go:generate -command INSTALL-HADOLINT sh -c ".buildcache/bin/hadolint --version 2>/dev/null | grep -wq \"$DOLLAR{DOLLAR}{1}\" || curl -sSfL https://github.com/hadolint/hadolint/releases/download/v\"$DOLLAR{DOLLAR}{1}\"/hadolint-\"$(uname)\"-x86_64 --output ./.buildcache/bin/hadolint && chmod +x .buildcache/bin/hadolint" -sh 8 | //go:generate -command INSTALL-SHELLCHECK sh -c ".buildcache/bin/shellcheck --version 2>/dev/null | grep -wq \"$DOLLAR{DOLLAR}{1}\" || curl -sSfL https://github.com/koalaman/shellcheck/releases/download/v\"$DOLLAR{DOLLAR}{1}\"/shellcheck-v\"$DOLLAR{DOLLAR}{1}\".\"$(uname)\".x86_64.tar.xz | tar xJf - -C .buildcache/bin --strip-components=1 shellcheck-v\"$DOLLAR{DOLLAR}{1}\"/shellcheck" -sh 9 | 10 | package tools 11 | 12 | //go:generate GOINSTALL github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0 13 | //go:generate GOINSTALL github.com/mattn/goveralls@v0.0.12 14 | //go:generate GOINSTALL github.com/tcnksm/ghr@v0.14.0 15 | //go:generate GOINSTALL gotest.tools/gotestsum@v1.12.0 16 | //go:generate INSTALL-HADOLINT 2.12.0 17 | //go:generate INSTALL-SHELLCHECK 0.10.0 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # Go version is also in .github/workflows/CI&CD.yml. 4 | FROM golang:1.23.2-alpine3.20 AS builder 5 | SHELL ["/bin/ash","-e","-o","pipefail","-x","-c"] 6 | 7 | LABEL org.opencontainers.image.source="https://github.com/powerman/dockerize" 8 | 9 | # hadolint ignore=DL3019 10 | RUN apk update; \ 11 | apk add openssl=~3 git=~2; \ 12 | apk add upx=~4 || :; \ 13 | rm -f /var/cache/apk/* 14 | 15 | COPY . /src 16 | WORKDIR /src 17 | 18 | RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod \ 19 | CGO_ENABLED=0 go install -ldflags "-s -w -X 'main.ver=$(git describe --match='v*' --exact-match)'" && \ 20 | ! which upx >/dev/null || upx /go/bin/dockerize && \ 21 | dockerize --version 22 | 23 | FROM alpine:3.20.3 24 | 25 | COPY --from=builder /go/bin/dockerize /usr/local/bin 26 | 27 | ENTRYPOINT ["dockerize"] 28 | CMD ["--help"] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014-2018 Jason Wilder 4 | Copyright (c) 2018-2024 Alex Efros 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dockerize 2 | 3 | [![Release](https://img.shields.io/github/v/release/powerman/dockerize.svg)](https://github.com/powerman/dockerize/releases/latest) 4 | [![Docker Automated Build](https://img.shields.io/docker/automated/powerman/dockerize.svg)](https://hub.docker.com/r/powerman/dockerize/tags) 5 | [![CI/CD](https://github.com/powerman/dockerize/actions/workflows/CI&CD.yml/badge.svg)](https://github.com/powerman/dockerize/actions/workflows/CI&CD.yml) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/powerman/dockerize)](https://goreportcard.com/report/github.com/powerman/dockerize) 7 | [![Coverage Status](https://coveralls.io/repos/github/powerman/dockerize/badge.svg?branch=master)](https://coveralls.io/github/powerman/dockerize?branch=master) 8 | 9 | Utility to simplify running applications in docker containers. 10 | 11 | **About this fork:** This fork is supposed to become a community-maintained replacement for 12 | [not maintained](https://github.com/powerman/dockerize/issues/19) 13 | [original repo](https://github.com/jwilder/dockerize). Everyone who has 14 | contributed to the project may become a collaborator - just ask for it 15 | in PR comments after your PR has being merged. 16 | 17 | 18 | 19 | **Table of Contents** 20 | 21 | - [Overview](#overview) 22 | - [Installation](#installation) 23 | - [Docker Base Image](#docker-base-image) 24 | - [Install in Docker Image](#install-in-docker-image) 25 | - [Usage](#usage) 26 | - [Command-line Options](#command-line-options) 27 | - [Waiting for other dependencies](#waiting-for-other-dependencies) 28 | - [Timeout](#timeout) 29 | - [Delay before retrying](#delay-before-retrying) 30 | - [Use custom CA for SSL cert verification for https/amqps connections](#use-custom-ca-for-ssl-cert-verification-for-httpsamqps-connections) 31 | - [Skip SSL cert verification for https/amqps connections](#skip-ssl-cert-verification-for-httpsamqps-connections) 32 | - [Injecting env vars from INI file](#injecting-env-vars-from-ini-file) 33 | - [Using Templates](#using-templates) 34 | - [jsonQuery](#jsonquery) 35 | 36 | 37 | 38 | ## Overview 39 | 40 | dockerize is a utility to simplify running applications in docker containers. It allows you to: 41 | * generate application configuration files at container startup time from templates and container environment variables 42 | * Tail multiple log files to stdout and/or stderr 43 | * Wait for other services to be available using TCP, HTTP(S), unix before starting the main process. 44 | 45 | The typical use case for dockerize is when you have an application that has one or more configuration files and you would like to control some of the values using environment variables. 46 | 47 | For example, a Python application using Sqlalchemy might not be able to use environment variables directly. 48 | It may require that the database URL be read from a python settings file with a variable named 49 | `SQLALCHEMY_DATABASE_URI`. dockerize allows you to set an environment variable such as 50 | `DATABASE_URL` and update the python file when the container starts. 51 | In addition, it can also delay the starting of the python application until the database container is running and listening on the TCP port. 52 | 53 | Another use case is when the application logs to specific files on the filesystem and not stdout 54 | or stderr. This makes it difficult to troubleshoot the container using the `docker logs` command. 55 | For example, nginx will log to `/var/log/nginx/access.log` and 56 | `/var/log/nginx/error.log` by default. While you can sometimes work around this, it's tedious to find a solution for every application. dockerize allows you to specify which logs files should be tailed and where they should be sent. 57 | 58 | See [A Simple Way To Dockerize Applications](http://jasonwilder.com/blog/2014/10/13/a-simple-way-to-dockerize-applications/) 59 | 60 | 61 | ## Installation 62 | 63 | Dockerize is a statically compiled binary, so it should work with any base image. 64 | 65 | To download it with most base images all you need is to install `curl` first: 66 | 67 | ```sh 68 | ### alpine: 69 | apk add curl 70 | 71 | ### debian, ubuntu: 72 | apt update && apt install -y curl 73 | ``` 74 | 75 | and then either install the latest version: 76 | 77 | ```sh 78 | curl -sfL $(curl -s https://api.github.com/repos/powerman/dockerize/releases/latest | grep -i /dockerize-$(uname -s)-$(uname -m)\" | cut -d\" -f4) | install /dev/stdin /usr/local/bin/dockerize 79 | ``` 80 | 81 | or specific version: 82 | 83 | ```sh 84 | curl -sfL https://github.com/powerman/dockerize/releases/download/v0.11.5/dockerize-`uname -s`-`uname -m` | install /dev/stdin /usr/local/bin/dockerize 85 | ``` 86 | 87 | If `curl` is not available (e.g. busybox base image) then you can use `wget`: 88 | 89 | ``` 90 | ### busybox: latest version 91 | wget -O - $(wget -O - https://api.github.com/repos/powerman/dockerize/releases/latest | grep -i /dockerize-$(uname -s)-$(uname -m)\" | cut -d\" -f4) | install /dev/stdin /usr/local/bin/dockerize 92 | 93 | ### busybox: specific version 94 | wget -O - https://github.com/powerman/dockerize/releases/download/v0.11.5/dockerize-`uname -s`-`uname -m` | install /dev/stdin /usr/local/bin/dockerize 95 | ``` 96 | 97 | PGP public key for verifying signed binaries: https://powerman.name/about/Powerman.asc 98 | 99 | ``` 100 | curl -sfL https://powerman.name/about/Powerman.asc | gpg --import 101 | curl -sfL https://github.com/powerman/dockerize/releases/download/v0.11.5/dockerize-`uname -s`-`uname -m`.asc >dockerize.asc 102 | gpg --verify dockerize.asc /usr/local/bin/dockerize 103 | ``` 104 | 105 | ### Docker Base Image 106 | 107 | The `powerman/dockerize` image is a base image based on `alpine linux`. `dockerize` is installed in the `$PATH` and can be used directly. 108 | 109 | ``` 110 | FROM powerman/dockerize 111 | ... 112 | ENTRYPOINT dockerize ... 113 | ``` 114 | 115 | ### Install in Docker Image 116 | 117 | You can use multi-stage build feature to install `dockerize` in your docker image without changing base image: 118 | 119 | ```dockerfile 120 | FROM powerman/dockerize:0.19.0 AS dockerize 121 | FROM node:18-slim 122 | ... 123 | COPY --from=dockerize /usr/local/bin/dockerize /usr/local/bin/ 124 | ... 125 | ENTRYPOINT ["dockerize", ...] 126 | ``` 127 | 128 | ## Usage 129 | 130 | dockerize works by wrapping the call to your application using the `ENTRYPOINT` or `CMD` directives. 131 | 132 | This would generate `/etc/nginx/nginx.conf` from the template located at `/etc/nginx/nginx.tmpl` and 133 | send `/var/log/nginx/access.log` to `STDOUT` and `/var/log/nginx/error.log` to `STDERR` after running 134 | `nginx`, only after waiting for the `web` host to respond on `tcp 8000`: 135 | 136 | ``` Dockerfile 137 | CMD dockerize -template /etc/nginx/nginx.tmpl:/etc/nginx/nginx.conf -stdout /var/log/nginx/access.log -stderr /var/log/nginx/error.log -wait tcp://web:8000 nginx 138 | ``` 139 | 140 | ### Command-line Options 141 | 142 | You can specify multiple templates by passing using `-template` multiple times: 143 | 144 | ``` 145 | $ dockerize -template template1.tmpl:file1.cfg -template template2.tmpl:file3 146 | 147 | ``` 148 | 149 | Templates can be generated to `STDOUT` by not specifying a dest: 150 | 151 | ``` 152 | $ dockerize -template template1.tmpl 153 | 154 | ``` 155 | 156 | Template may also be a directory. In this case all files within this directory are recursively processed as template and stored with the same name in the destination directory. 157 | If the destination directory is omitted, the output is sent to `STDOUT`. The files in the source directory are processed in sorted order (as returned by `ioutil.ReadDir`). 158 | 159 | ``` 160 | $ dockerize -template src_dir:dest_dir 161 | 162 | ``` 163 | 164 | If the destination file already exists, dockerize will overwrite it. The -no-overwrite flag overrides this behaviour. 165 | 166 | ``` 167 | $ dockerize -no-overwrite -template template1.tmpl:file 168 | ``` 169 | 170 | You can tail multiple files to `STDOUT` and `STDERR` by passing the options multiple times. 171 | (These options can't be combined with `-exec`.) 172 | 173 | ``` 174 | $ dockerize -stdout info.log -stdout perf.log 175 | 176 | ``` 177 | 178 | If your file uses `{{` and `}}` as part of it's syntax, you can change the template escape characters using the `-delims`. 179 | 180 | ``` 181 | $ dockerize -delims "<%:%>" -template template1.tmpl 182 | ``` 183 | 184 | You can require all environment variables mentioned in template exists 185 | with `-template-strict`: 186 | 187 | ``` 188 | $ dockerize -template-strict -template template1.tmpl 189 | ``` 190 | 191 | HTTP headers can be specified for http/https protocols. 192 | If header is specified as a file path then file must contain single string with `Header: value`. 193 | 194 | ``` 195 | $ dockerize -wait http://web:80 -wait-http-header "Authorization:Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" 196 | ``` 197 | 198 | Required HTTP status codes can be specified, otherwise any 2xx status will 199 | be accepted. 200 | 201 | ``` 202 | $ dockerize -wait http://web:80 -wait-http-status-code 302 -wait-http-status-code 200 203 | ``` 204 | 205 | HTTP redirects can be ignored: 206 | 207 | ``` 208 | $ dockerize -wait http://web:80 -wait-http-skip-redirect 209 | ``` 210 | 211 | Dockerize process can be replaced with given command: 212 | 213 | ``` 214 | $ dockerize -exec some-command args... 215 | ``` 216 | 217 | ### Waiting for other dependencies 218 | 219 | It is common when using tools like [Docker Compose](https://docs.docker.com/compose/) to depend on services in other linked containers, however oftentimes relying on [links](https://docs.docker.com/compose/compose-file/#links) is not enough - whilst the container itself may have _started_, the _service(s)_ within it may not yet be ready - resulting in shell script hacks to work around race conditions. 220 | 221 | Dockerize gives you the ability to wait for services on a specified protocol (`file`, `tcp`, `tcp4`, `tcp6`, `http`, `https`, `amqp`, `amqps` and `unix`) before starting your application: 222 | 223 | ``` 224 | $ dockerize -wait tcp://db:5432 -wait http://web:80 -wait file:///tmp/generated-file 225 | ``` 226 | 227 | Multiple URLs can also be specified with `-wait-list` flag, that accept a space-separated list of URLs. The behaviour is equivalent to use multiple `-wait` flags. 228 | The two flags can be combined. 229 | 230 | This command is equivalent to the one above: 231 | 232 | ``` 233 | $ dockerize -wait-list "tcp://db:5432 http://web:80 file:///tmp/generated-file" 234 | ``` 235 | 236 | ### Timeout 237 | 238 | You can optionally specify how long to wait for the services to become available by using the `-timeout #` argument (Default: 10 seconds). If the timeout is reached and the service is still not available, the process exits with status code 123. 239 | 240 | ``` 241 | $ dockerize -wait tcp://db:5432 -wait http://web:80 -timeout 10s 242 | ``` 243 | 244 | See [this issue](https://github.com/docker/compose/issues/374#issuecomment-126312313) for a deeper discussion, and why support isn't and won't be available in the Docker ecosystem itself. 245 | 246 | ### Delay before retrying 247 | 248 | You can optionally specify how long to wait after a failed `-wait` check by using the `-wait-retry-interval #` argument (Default: 1 second). 249 | 250 | Waiting for 5 seconds before checking again of a currently unavailable service: 251 | 252 | ``` 253 | $ dockerize -wait tcp://db:5432 -wait-retry-interval 5s 254 | ``` 255 | 256 | ### Use custom CA for SSL cert verification for https/amqps connections 257 | 258 | ``` 259 | $ dockerize -cacert /path/to/ca.pem -wait https://web:80 260 | ``` 261 | 262 | ### Skip SSL cert verification for https/amqps connections 263 | 264 | ``` 265 | $ dockerize -skip-tls-verify -wait https://web:80 266 | ``` 267 | 268 | ### Injecting env vars from INI file 269 | 270 | You can load defaults for missing env vars from INI file. 271 | Multiline flag allows parsing multiline INI entries. 272 | File with header must contain single string with `Header: value`. 273 | 274 | ``` 275 | $ dockerize -env /path/to/file.ini -env-section SectionName -multiline … 276 | $ dockerize -env http://localhost:80/file.ini \ 277 | -env-header "Header: value" -env-header /path/to/file/with/header … 278 | ``` 279 | 280 | ## Using Templates 281 | 282 | Templates use Golang [text/template](http://golang.org/pkg/text/template/). You can access environment 283 | variables within a template with `.Env`. 284 | 285 | ``` 286 | {{ .Env.PATH }} is my path 287 | ``` 288 | 289 | In template you can use a lot of [functions provided by 290 | Sprig](http://masterminds.github.io/sprig/) plus a few built in functions as well: 291 | 292 | * `exists $path` - Determines if a file path exists or not. `{{ if exists "/etc/default/myapp" }}` 293 | * `parseUrl $url` - Parses a URL into it's [protocol, scheme, host, etc. parts](https://golang.org/pkg/net/url/#URL). Alias for [`url.Parse`](https://golang.org/pkg/net/url/#Parse) 294 | * `isTrue $value` - Parses a string $value to a boolean value. `{{ if isTrue .Env.ENABLED }}` 295 | * `jsonQuery $json $query` - Returns the result of a selection query against a json document. 296 | * `readFile $fileName` - Returns the content of the named file or empty string if file not exists. 297 | 298 | **WARNING! Incompatibility with [original dockerize 299 | v0.6.1](https://github.com/jwilder/dockerize)!** These template functions 300 | was changed because of adding Sprig functions, so carefully review your 301 | templates before upgrading: 302 | 303 | * `default` - order of params has changed. 304 | * `contains` - now it works on string instead of map, use `hasKey` instead. 305 | * `split` - now it split into map instead of list, use `splitList` instead. 306 | * `replace` - order and amount of params has changed. 307 | * `loop` - removed, use `untilStep` instead. 308 | 309 | ### jsonQuery 310 | 311 | Objects and fields are accessed by name. Array elements are accessed by index in square brackets (e.g. `[1]`). Nested elements are separated by dots (`.`). 312 | 313 | **Examples:** 314 | 315 | With the following JSON in `.Env.SERVICES` 316 | 317 | ``` 318 | { 319 | "services": [ 320 | { 321 | "name": "service1", 322 | "port": 8000, 323 | },{ 324 | "name": "service2", 325 | "port": 9000, 326 | } 327 | ] 328 | } 329 | ``` 330 | 331 | the template expression `jsonQuery .Env.SERVICES "services.[1].port"` returns `9000`. 332 | -------------------------------------------------------------------------------- /env.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | ) 7 | 8 | func setDefaultEnv(defaultEnv map[string]string) { 9 | for k, v := range defaultEnv { 10 | if _, ok := os.LookupEnv(k); !ok { 11 | if err := os.Setenv(k, v); err != nil { 12 | fatalf("Failed to set environment: %s.", err) 13 | } 14 | } 15 | } 16 | } 17 | 18 | func getEnv() map[string]string { 19 | env := make(map[string]string) 20 | for _, kv := range os.Environ() { 21 | name, val, _ := strings.Cut(kv, "=") 22 | env[name] = val 23 | } 24 | return env 25 | } 26 | -------------------------------------------------------------------------------- /examples/json/json-example: -------------------------------------------------------------------------------- 1 | {{ with $jsonDoc := `{ 2 | "services": [ 3 | { 4 | "name": "service1", 5 | "port": 8000 6 | }, { 7 | "name": "service2", 8 | "port": 9000 9 | } 10 | ] 11 | }` }} 12 | 13 | NAME0={{ jsonQuery $jsonDoc "services.[0].name" }} 14 | PORT0={{ jsonQuery $jsonDoc "services.[0].port" }} 15 | NAME1={{ jsonQuery $jsonDoc "services.[1].name" }} 16 | PORT1={{ jsonQuery $jsonDoc "services.[1].port" }} 17 | 18 | {{ range $index, $value := jsonQuery $jsonDoc "services" }} 19 | Index: {{ printf "%v" $index }} 20 | Name: {{ printf "%v" $value.name }} 21 | Port: {{ printf "%v" $value.port }} 22 | --- 23 | {{end}} 24 | 25 | {{end}} -------------------------------------------------------------------------------- /examples/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:18.04 2 | 3 | RUN apt-get update && apt-get install -y curl nginx 4 | 5 | RUN curl -sfL $(curl -s https://api.github.com/repos/powerman/dockerize/releases/latest | grep -i /dockerize-$(uname -s)-$(uname -m)\" | cut -d\" -f4) | install /dev/stdin /usr/local/bin/dockerize 6 | 7 | RUN echo "daemon off;" >> /etc/nginx/nginx.conf 8 | 9 | ADD default.tmpl /etc/nginx/sites-available/default.tmpl 10 | 11 | EXPOSE 80 12 | 13 | CMD ["dockerize", "-template", "/etc/nginx/sites-available/default.tmpl:/etc/nginx/sites-available/default", "-stdout", "/var/log/nginx/access.log", "-stderr", "/var/log/nginx/error.log", "nginx"] 14 | -------------------------------------------------------------------------------- /examples/nginx/default.tmpl: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server ipv6only=on; 4 | 5 | root /usr/share/nginx/html; 6 | index index.html index.htm; 7 | 8 | # Make site accessible from http://localhost/ 9 | server_name localhost; 10 | 11 | location / { 12 | access_log off; 13 | proxy_pass {{ .Env.PROXY_URL }}; 14 | proxy_set_header X-Real-IP $remote_addr; 15 | proxy_set_header Host $host; 16 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /exec.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "os/signal" 7 | "syscall" 8 | ) 9 | 10 | func runCmd(name string, args ...string) (int, error) { 11 | cmd := exec.Command(name, args...) 12 | cmd.Stdin = os.Stdin 13 | cmd.Stdout = os.Stdout 14 | cmd.Stderr = os.Stderr 15 | setSysProcAttr(cmd) 16 | 17 | err := cmd.Start() 18 | if err != nil { 19 | return 0, err 20 | } 21 | 22 | const sigcSize = 8 23 | sigc := make(chan os.Signal, sigcSize) 24 | signal.Notify(sigc, 25 | syscall.SIGHUP, 26 | syscall.SIGINT, 27 | syscall.SIGQUIT, 28 | syscall.SIGABRT, 29 | syscall.SIGUSR1, 30 | syscall.SIGUSR2, 31 | syscall.SIGALRM, 32 | syscall.SIGTERM, 33 | ) 34 | go func() { 35 | for sig := range sigc { 36 | // This will duplicate some signals if they're 37 | // sent to all processes in current group (like 38 | // when Ctrl-C is pressed in shell). 39 | _ = cmd.Process.Signal(sig) 40 | } 41 | }() 42 | 43 | _ = cmd.Wait() 44 | 45 | signal.Stop(sigc) 46 | close(sigc) 47 | 48 | return cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus(), nil 49 | } 50 | -------------------------------------------------------------------------------- /exec_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package main 5 | 6 | import ( 7 | "os/exec" 8 | "syscall" 9 | ) 10 | 11 | func setSysProcAttr(cmd *exec.Cmd) { 12 | cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} 13 | } 14 | -------------------------------------------------------------------------------- /exec_other.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | // +build !linux 3 | 4 | package main 5 | 6 | import ( 7 | "os/exec" 8 | ) 9 | 10 | func setSysProcAttr(cmd *exec.Cmd) {} 11 | -------------------------------------------------------------------------------- /flag.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | var ( 14 | errNameValueMultiline = errors.New("name:value must be a single line") 15 | errNameValueRequired = errors.New("must be a name:value") 16 | errStatusCodeOutOfRange = errors.New("status code must be between 100 and 599") 17 | errLeftRightRequired = errors.New("must be a left:right") 18 | ) 19 | 20 | type stringsFlag []string 21 | 22 | func (f *stringsFlag) Set(value string) error { 23 | *f = append(*f, value) 24 | return nil 25 | } 26 | 27 | func (f stringsFlag) String() string { 28 | return strings.Join(f, ",") 29 | } 30 | 31 | type urlsFlag []*url.URL 32 | 33 | func (f *urlsFlag) Set(value string) error { 34 | u, err := url.Parse(value) 35 | if err != nil { 36 | return err 37 | } 38 | *f = append(*f, u) 39 | return nil 40 | } 41 | 42 | func (f urlsFlag) String() string { 43 | urls := make([]string, len(f)) 44 | for i := range f { 45 | urls[i] = f[i].String() 46 | } 47 | return strings.Join(urls, " ") 48 | } 49 | 50 | type httpHeader struct { 51 | name string 52 | value string 53 | } 54 | 55 | type httpHeadersFlag []httpHeader 56 | 57 | func (f *httpHeadersFlag) Set(value string) error { 58 | buf, err := os.ReadFile(value) //nolint:gosec // File inclusion via variable. 59 | if err == nil { 60 | value = string(buf) 61 | } else if !os.IsNotExist(err) { 62 | return err 63 | } 64 | value = strings.TrimSpace(value) 65 | if strings.ContainsAny(value, "\r\n") { 66 | return errNameValueMultiline 67 | } else if strings.Count(value, ":") == 0 { 68 | return errNameValueRequired 69 | } 70 | nv := strings.SplitN(value, ":", 1+1) 71 | for i := range nv { 72 | nv[i] = strings.TrimSpace(nv[i]) 73 | if nv[i] == "" { 74 | return errNameValueRequired 75 | } 76 | } 77 | *f = append(*f, httpHeader{name: nv[0], value: nv[1]}) 78 | return nil 79 | } 80 | 81 | func (f httpHeadersFlag) String() string { 82 | hs := make([]string, len(f)) 83 | for i := range f { 84 | hs[i] = f[i].name + ":" + f[i].value 85 | } 86 | return strings.Join(hs, ", ") 87 | } 88 | 89 | type statusCodesFlag []int 90 | 91 | func (f *statusCodesFlag) Set(value string) error { 92 | i, err := strconv.Atoi(value) 93 | if err != nil { 94 | return err 95 | } 96 | if i < 100 || 599 < i { 97 | return errStatusCodeOutOfRange 98 | } 99 | *f = append(*f, i) 100 | return nil 101 | } 102 | 103 | func (f statusCodesFlag) String() string { 104 | ns := make([]string, len(f)) 105 | for i := range f { 106 | ns[i] = strconv.Itoa(f[i]) 107 | } 108 | return strings.Join(ns, ", ") 109 | } 110 | 111 | type delimsFlag [2]string 112 | 113 | func (f *delimsFlag) Set(value string) error { 114 | delims := strings.Split(value, ":") 115 | if len(delims) != 2 || len(strings.Fields(delims[0])) != 1 || len(strings.Fields(delims[1])) != 1 { 116 | return errLeftRightRequired 117 | } 118 | (*f)[0] = delims[0] 119 | (*f)[1] = delims[1] 120 | return nil 121 | } 122 | 123 | func (f delimsFlag) String() string { 124 | return f[0] + ":" + f[1] 125 | } 126 | 127 | // fatalFlagValue report invalid flag values in same way as flag.Parse(). 128 | func fatalFlagValue(msg, name string, val any) { 129 | fmt.Fprintf(os.Stderr, "invalid value %v for flag -%s: %s\n", val, name, msg) 130 | flag.Usage() 131 | os.Exit(exitCodeUsage) //nolint:revive // By design. 132 | } 133 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/powerman/dockerize 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/Masterminds/sprig/v3 v3.3.0 9 | github.com/jwilder/gojq v0.0.0-20161018055142-c550732d4a52 10 | github.com/powerman/check v1.7.0 11 | github.com/powerman/gotest v0.3.0 12 | github.com/powerman/tail v0.1.0 13 | github.com/smartystreets/goconvey v1.8.1 14 | github.com/streadway/amqp v1.1.0 15 | gopkg.in/ini.v1 v1.67.0 16 | ) 17 | 18 | require ( 19 | dario.cat/mergo v1.0.1 // indirect 20 | github.com/Masterminds/goutils v1.1.1 // indirect 21 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 22 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 23 | github.com/elgs/gosplitargs v0.0.0-20161028071935-a491c5eeb3c8 // indirect 24 | github.com/google/uuid v1.6.0 // indirect 25 | github.com/gopherjs/gopherjs v1.17.2 // indirect 26 | github.com/huandu/xstrings v1.5.0 // indirect 27 | github.com/jtolds/gls v4.20.0+incompatible // indirect 28 | github.com/mitchellh/copystructure v1.2.0 // indirect 29 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 30 | github.com/pkg/errors v0.9.1 // indirect 31 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 32 | github.com/powerman/deepequal v0.1.0 // indirect 33 | github.com/rogpeppe/go-internal v1.12.0 // indirect 34 | github.com/shopspring/decimal v1.4.0 // indirect 35 | github.com/smarty/assertions v1.16.0 // indirect 36 | github.com/spf13/cast v1.7.0 // indirect 37 | github.com/stretchr/testify v1.9.0 // indirect 38 | golang.org/x/crypto v0.28.0 // indirect 39 | golang.org/x/sys v0.26.0 // indirect 40 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3 // indirect 41 | google.golang.org/grpc v1.65.0 // indirect 42 | google.golang.org/protobuf v1.34.1 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 4 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 5 | github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= 6 | github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 7 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 8 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 11 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/elgs/gosplitargs v0.0.0-20161028071935-a491c5eeb3c8 h1:bD2/rCXwgXJm2vgoSSSCM9IPjVFfEoQFFblzg7HHABI= 13 | github.com/elgs/gosplitargs v0.0.0-20161028071935-a491c5eeb3c8/go.mod h1:o4DgpccPNAQAlPSxo7I4L/LWNh2oyr/BBGSynrLTmZM= 14 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 15 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 16 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 17 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 18 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 19 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 20 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 21 | github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 22 | github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 23 | github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 24 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 25 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 26 | github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 27 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 28 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 29 | github.com/jwilder/gojq v0.0.0-20161018055142-c550732d4a52 h1:ZSTiJFRPQr2XRqfgvm2xpEsrsudezdk8ykBXXiJDfiQ= 30 | github.com/jwilder/gojq v0.0.0-20161018055142-c550732d4a52/go.mod h1:pD7F1lLmlib/2Vy3xild2aXjNnnSudq54IJGftfO4O0= 31 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 32 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 33 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 34 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 35 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 36 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 37 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 38 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 39 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 40 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 41 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 43 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 44 | github.com/powerman/check v1.0.1/go.mod h1:wOYaLZeuhk6i79TDfPcqkDx70tbaVLEiE0aqMhMKojc= 45 | github.com/powerman/check v1.7.0 h1:PtRow0L73QgYSmXUBI5qe5MnDu3kowTAKQSHTbDH8Zs= 46 | github.com/powerman/check v1.7.0/go.mod h1:pCQPDCCVj1ksGj9OaMqFBjvet5Jg8TbMB3UJj8Nx98g= 47 | github.com/powerman/deepequal v0.1.0 h1:sVwtyTsBuYIvdbLR1O2wzRY63YgPqdGZmk/o80l+C/U= 48 | github.com/powerman/deepequal v0.1.0/go.mod h1:3k7aG/slufBhUANdN67o/UPg8i5YaiJ6FmibWX0cn04= 49 | github.com/powerman/gotest v0.3.0 h1:3HdlkyT8XoTIw220BvCP+yy7OhB1AxZ11Qfzn00l3YQ= 50 | github.com/powerman/gotest v0.3.0/go.mod h1:vR8gUJorGFev+TucLyhbbx566IelaJFxCSAcDYD4fdY= 51 | github.com/powerman/tail v0.1.0 h1:EznVt+S27fLfHlf8oFV55azSWx24i7inhTvVT+z4dtM= 52 | github.com/powerman/tail v0.1.0/go.mod h1:1UwnzK89DQ07dUa3H2MY8SoOhY5krVwHqkxRkuSZBlY= 53 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 54 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 55 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 56 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 57 | github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY= 58 | github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI= 59 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 60 | github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= 61 | github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs= 62 | github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= 63 | github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= 64 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 65 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 66 | github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= 67 | github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= 68 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 69 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 70 | golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= 71 | golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 72 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 73 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 74 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 75 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 76 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 77 | golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= 78 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 79 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3 h1:9Xyg6I9IWQZhRVfCWjKK+l6kI0jHcPesVlMnT//aHNo= 80 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= 81 | google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= 82 | google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= 83 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 84 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 85 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 86 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 87 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 88 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 89 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 90 | -------------------------------------------------------------------------------- /ini.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | 14 | ini "gopkg.in/ini.v1" 15 | ) 16 | 17 | var ( 18 | errRedirectsDisallowed = errors.New("redirects disallowed") 19 | errBadStatusCode = errors.New("bad HTTP status") 20 | ) 21 | 22 | type iniConfig struct { 23 | source string // URL or file path 24 | options ini.LoadOptions 25 | section string 26 | headers httpHeadersFlag 27 | skipTLSVerify bool 28 | ca *x509.CertPool 29 | } 30 | 31 | func loadINISection(cfg iniConfig) (map[string]string, error) { 32 | if cfg.source == "" { 33 | return nil, nil //nolint:nilnil // TODO. 34 | } 35 | 36 | var data []byte 37 | u, err := url.Parse(cfg.source) 38 | if err == nil && u.IsAbs() { 39 | data, err = fetchINI(cfg) 40 | } else { 41 | data, err = os.ReadFile(cfg.source) 42 | } 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | file, err := ini.LoadSources(cfg.options, data) 48 | if err != nil { 49 | return nil, err 50 | } 51 | return file.Section(cfg.section).KeysHash(), nil 52 | } 53 | 54 | func fetchINI(cfg iniConfig) (data []byte, err error) { 55 | client := &http.Client{ 56 | Transport: &http.Transport{ 57 | Proxy: http.ProxyFromEnvironment, 58 | TLSClientConfig: &tls.Config{ 59 | InsecureSkipVerify: cfg.skipTLSVerify, //nolint:gosec // TLS InsecureSkipVerify may be true. 60 | RootCAs: cfg.ca, 61 | }, 62 | }, 63 | CheckRedirect: func(*http.Request, []*http.Request) error { 64 | return errRedirectsDisallowed 65 | }, 66 | } 67 | 68 | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, cfg.source, http.NoBody) 69 | if err != nil { 70 | return nil, err 71 | } 72 | for _, h := range cfg.headers { 73 | req.Header.Add(h.name, h.value) 74 | } 75 | 76 | resp, err := client.Do(req) //nolint:bodyclose // False positive. 77 | if err != nil { 78 | return nil, err 79 | } 80 | defer warnIfFail(resp.Body.Close) 81 | 82 | if resp.StatusCode != http.StatusOK { 83 | return nil, fmt.Errorf("%w: %d", errBadStatusCode, resp.StatusCode) 84 | } 85 | return io.ReadAll(resp.Body) 86 | } 87 | -------------------------------------------------------------------------------- /init_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "os" 7 | "strconv" 8 | "testing" 9 | "time" 10 | 11 | "github.com/powerman/check" 12 | _ "github.com/smartystreets/goconvey/convey" 13 | ) 14 | 15 | var ( 16 | testTimeFactor = floatGetenv("GO_TEST_TIME_FACTOR", 1.0) 17 | testSecond = time.Duration(testTimeFactor) * time.Second //nolint:revive // By design. 18 | testCtx = context.Background() 19 | ) 20 | 21 | func floatGetenv(name string, def float64) float64 { 22 | if v, err := strconv.ParseFloat(os.Getenv(name), 64); err == nil { 23 | return v 24 | } 25 | return def 26 | } 27 | 28 | type checkC struct{ *check.C } 29 | 30 | func checkT(t *testing.T) *checkC { t.Helper(); return &checkC{C: check.T(t)} } 31 | func (c *checkC) NoErr(_ any, err error) { c.Helper(); c.Must(c.Nil(err)) } 32 | func (c *checkC) NoErrInt(v int, err error) int { c.Helper(); c.Must(c.Nil(err)); return v } 33 | func (c *checkC) NoErrBuf(v []byte, err error) []byte { c.Helper(); c.Must(c.Nil(err)); return v } 34 | func (c *checkC) NoErrFile(v *os.File, err error) *os.File { c.Helper(); c.Must(c.Nil(err)); return v } 35 | func (c *checkC) NoErrListen(v net.Listener, err error) net.Listener { 36 | c.Helper() 37 | c.Must(c.Nil(err)) 38 | return v 39 | } 40 | 41 | func (c *checkC) TempPath() string { 42 | c.Helper() 43 | f := c.NoErrFile(os.CreateTemp("", "gotest")) 44 | c.Nil(f.Close()) 45 | c.Nil(os.Remove(f.Name())) 46 | return f.Name() 47 | } 48 | 49 | func TestMain(m *testing.M) { 50 | if os.Getenv("GO_WANT_HELPER_PROCESS") == "" { // don't do this again in subprocess 51 | // Vars for use in testdata/ templates. 52 | os.Setenv("A", "10") 53 | os.Setenv("B", "20") 54 | os.Unsetenv("C") 55 | } 56 | 57 | check.TestMain(m) 58 | } 59 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Tool dockerize simplify running applications in docker containers. 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "log" 8 | urlpkg "net/url" 9 | "os" 10 | "os/exec" 11 | "path" 12 | "runtime" 13 | "strings" 14 | "syscall" 15 | "time" 16 | ) 17 | 18 | const ( 19 | schemeFile = "file" 20 | schemeTCP = "tcp" 21 | schemeTCP4 = "tcp4" 22 | schemeTCP6 = "tcp6" 23 | schemeUnix = "unix" 24 | schemeHTTP = "http" 25 | schemeHTTPS = "https" 26 | schemeAMQP = "amqp" 27 | schemeAMQPS = "amqps" 28 | defWaitTimeout = 10 * time.Second 29 | defWaitRetryInterval = time.Second 30 | exitCodeUsage = 2 31 | exitCodeFatal = 123 32 | ) 33 | 34 | // Read-only globals for use only within init() and main(). 35 | // 36 | //nolint:gochecknoglobals // By design. 37 | var ( 38 | app = strings.TrimSuffix(path.Base(os.Args[0]), ".test") 39 | ver = "unknown" // set by ./release 40 | cfg struct { 41 | version bool 42 | ini iniConfig 43 | templatePaths stringsFlag // file or file:file or dir or dir:dir 44 | template templateConfig 45 | waitURLs urlsFlag 46 | wait waitConfig 47 | caCert string 48 | tailStdout stringsFlag 49 | tailStderr stringsFlag 50 | exitCodeFatal int 51 | waitList string 52 | exec bool 53 | } 54 | ) 55 | 56 | // One-time initialization shared with tests. 57 | func init() { //nolint:gochecknoinits // By design. 58 | flag.BoolVar(&cfg.version, "version", false, "print version and exit") 59 | flag.StringVar(&cfg.ini.source, "env", "", "path or URL to INI file with default values for unset env vars") 60 | flag.BoolVar(&cfg.ini.options.AllowPythonMultilineValues, "multiline", false, "allow Python-like multi-line values in INI file") 61 | flag.StringVar(&cfg.ini.section, "env-section", "", "section name in INI file") 62 | flag.Var(&cfg.ini.headers, "env-header", "`name:value` or path to file containing name:value for HTTP header to send\n(if -env is an URL)\ncan be passed multiple times") 63 | flag.Var(&cfg.templatePaths, "template", "template `src:dst` file or dir paths, :dst part is optional\ncan be passed multiple times") 64 | flag.BoolVar(&cfg.template.noOverwrite, "no-overwrite", false, "do not overwrite existing destination file from template") 65 | flag.BoolVar(&cfg.template.strict, "template-strict", false, "fail if template mention unset environment variable") 66 | flag.Var(&cfg.template.delims, "delims", "action delimiters in templates") 67 | flag.Var(&cfg.waitURLs, "wait", "wait for `url` (file/tcp/tcp4/tcp6/unix/http/https/amqp/amqps)\ncan be passed multiple times") 68 | flag.Var(&cfg.wait.headers, "wait-http-header", "`name:value` for HTTP header to send\n(if -wait use HTTP)\ncan be passed multiple times") 69 | flag.BoolVar(&cfg.wait.skipTLSVerify, "skip-tls-verify", false, "skip TLS verification for HTTPS/AMQPS -wait and -env urls") 70 | flag.StringVar(&cfg.caCert, "cacert", "", "path to CA certificate for HTTPS/AMQPS -wait and -env urls") 71 | flag.BoolVar(&cfg.wait.skipRedirect, "wait-http-skip-redirect", false, "do not follow HTTP redirects\n(if -wait use HTTP)") 72 | flag.Var(&cfg.wait.statusCodes, "wait-http-status-code", "HTTP status `code` to wait for (2xx by default)\ncan be passed multiple times") 73 | flag.DurationVar(&cfg.wait.timeout, "timeout", defWaitTimeout, "timeout for -wait") 74 | flag.DurationVar(&cfg.wait.delay, "wait-retry-interval", defWaitRetryInterval, "delay before retrying failed -wait") 75 | flag.Var(&cfg.tailStdout, "stdout", "file `path` to tail to stdout\ncan be passed multiple times") 76 | flag.Var(&cfg.tailStderr, "stderr", "file `path` to tail to stderr\ncan be passed multiple times") 77 | flag.IntVar(&cfg.exitCodeFatal, "exit-code", exitCodeFatal, "exit code for dockerize errors") 78 | flag.StringVar(&cfg.waitList, "wait-list", "", "a space-separated list of URLs to wait for\ncan be combined with -wait flag") 79 | flag.BoolVar(&cfg.exec, "exec", false, "replace dockerize process with given command") 80 | 81 | flag.Usage = usage //nolint:reassign // By design. 82 | } 83 | 84 | func main() { //nolint:gocyclo,gocognit,funlen // TODO Refactor? 85 | if !flag.Parsed() { // flags may be already parsed by tests 86 | flag.Parse() 87 | } 88 | 89 | var iniURL, iniHTTP, templatePathBad, waitBadScheme, waitHTTP, waitAMQPS bool 90 | if u, err := urlpkg.Parse(cfg.ini.source); err == nil && u.IsAbs() { 91 | iniURL = true 92 | iniHTTP = u.Scheme == schemeHTTP || u.Scheme == schemeHTTPS 93 | } 94 | for _, tmplPath := range cfg.templatePaths { 95 | const maxParts = 2 96 | parts := strings.Split(tmplPath, ":") 97 | templatePathBad = templatePathBad || tmplPath == "" || parts[0] == "" || len(parts) > maxParts 98 | } 99 | 100 | waitListParts := strings.Fields(cfg.waitList) 101 | for _, url := range waitListParts { 102 | if err := cfg.waitURLs.Set(url); err != nil { 103 | fatalFlagValue("unable to parse URLs list", "wait-list", url) 104 | } 105 | } 106 | 107 | for _, u := range cfg.waitURLs { 108 | switch u.Scheme { 109 | case schemeFile, schemeTCP, schemeTCP4, schemeTCP6, schemeUnix: 110 | case schemeHTTP, schemeHTTPS: 111 | waitHTTP = true 112 | case schemeAMQP: 113 | case schemeAMQPS: 114 | waitAMQPS = true 115 | default: 116 | waitBadScheme = true 117 | } 118 | } 119 | switch { 120 | case flag.NArg() == 0 && flag.NFlag() == 0: 121 | flag.Usage() 122 | os.Exit(exitCodeUsage) 123 | case iniURL && !iniHTTP: 124 | fatalFlagValue("scheme must be http/https", "env", cfg.ini.source) 125 | case len(cfg.ini.headers) > 0 && !iniHTTP: 126 | fatalFlagValue("require -env with HTTP url", "env-header", cfg.ini.headers) 127 | case templatePathBad: 128 | fatalFlagValue("require src:dst or src", "template", cfg.templatePaths) 129 | case cfg.template.noOverwrite && len(cfg.templatePaths) == 0: 130 | fatalFlagValue("require -template", "no-overwrite", cfg.template.noOverwrite) 131 | case cfg.template.strict && len(cfg.templatePaths) == 0: 132 | fatalFlagValue("require -template", "template-strict", cfg.template.strict) 133 | case cfg.template.delims[0] != "" && len(cfg.templatePaths) == 0: 134 | fatalFlagValue("require -template", "delims", cfg.template.delims) 135 | case waitBadScheme: 136 | fatalFlagValue("scheme must be file/tcp/tcp4/tcp6/unix/http/https/amqp/amqps", "wait", cfg.waitURLs) 137 | case len(cfg.wait.headers) > 0 && !waitHTTP: 138 | fatalFlagValue("require -wait with HTTP url", "wait-http-header", cfg.wait.headers) 139 | case len(cfg.wait.statusCodes) > 0 && !waitHTTP: 140 | fatalFlagValue("require -wait with HTTP url", "wait-http-status-code", cfg.wait.statusCodes) 141 | case cfg.wait.skipRedirect && !waitHTTP: 142 | fatalFlagValue("require -wait with HTTP url", "wait-http-skip-redirect", cfg.wait.skipRedirect) 143 | case cfg.wait.skipTLSVerify && !iniHTTP && !waitHTTP && !waitAMQPS: 144 | fatalFlagValue("require -wait/-env with HTTP url", "skip-tls-verify", cfg.wait.skipTLSVerify) 145 | case cfg.caCert != "" && !iniHTTP && !waitHTTP && !waitAMQPS: 146 | fatalFlagValue("require -wait/-env with HTTP url", "cacert", cfg.caCert) 147 | case cfg.exec && len(cfg.tailStdout)+len(cfg.tailStderr) > 0: 148 | fatalFlagValue("using -exec with -stdout/-stderr is not supported", "exec", cfg.exec) 149 | case cfg.exec && flag.NArg() == 0: 150 | fatalFlagValue("require command to exec", "exec", cfg.exec) 151 | case cfg.version: 152 | fmt.Println(app, ver, runtime.Version()) 153 | os.Exit(0) 154 | } 155 | 156 | var err error 157 | cfg.ini.skipTLSVerify = cfg.wait.skipTLSVerify 158 | cfg.wait.ca, err = LoadCACert(cfg.caCert) 159 | if err != nil { 160 | fatalf("Failed to load CA cert: %s", err) 161 | } 162 | cfg.ini.ca = cfg.wait.ca 163 | if cfg.template.delims[0] == "" { 164 | cfg.template.delims = [2]string{"{{", "}}"} 165 | } 166 | 167 | defaultEnv, err := loadINISection(cfg.ini) 168 | if err != nil { 169 | fatalf("Failed to load INI: %s.", err) 170 | } 171 | 172 | setDefaultEnv(defaultEnv) 173 | 174 | cfg.template.data.Env = getEnv() 175 | err = processTemplatePaths(cfg.template, cfg.templatePaths) 176 | if err != nil { 177 | fatalf("Failed to process templates: %s.", err) 178 | } 179 | 180 | err = waitForURLs(cfg.wait, cfg.waitURLs) 181 | if err != nil { 182 | fatalf("Failed to wait: %s.", err) 183 | } 184 | 185 | for _, tailPath := range cfg.tailStdout { 186 | tailFile(tailPath, os.Stdout) 187 | } 188 | for _, tailPath := range cfg.tailStderr { 189 | tailFile(tailPath, os.Stderr) 190 | } 191 | 192 | switch { 193 | case cfg.exec: 194 | arg0, err := exec.LookPath(flag.Arg(0)) 195 | if err == nil { 196 | err = syscall.Exec(arg0, flag.Args(), os.Environ()) //nolint:gosec // False positive. 197 | } 198 | if err != nil { 199 | fatalf("Failed to run command: %s.", err) 200 | } 201 | case flag.NArg() > 0: 202 | code, err := runCmd(flag.Arg(0), flag.Args()[1:]...) 203 | if err != nil { 204 | fatalf("Failed to run command: %s.", err) 205 | } 206 | os.Exit(code) 207 | case len(cfg.tailStdout)+len(cfg.tailStderr) > 0: 208 | select {} 209 | } 210 | } 211 | 212 | func warnIfFail(f func() error) { 213 | if err := f(); err != nil { 214 | log.Printf("Warning: %s.", err) 215 | } 216 | } 217 | 218 | func fatalf(format string, v ...any) { 219 | log.Printf(format, v...) 220 | os.Exit(cfg.exitCodeFatal) //nolint:revive // By design. 221 | } 222 | 223 | func usage() { 224 | fmt.Println(`Usage: 225 | dockerize options [ command [ arg ... ] ] 226 | dockerize [ options ] command [ arg ... ] 227 | 228 | Utility to simplify running applications in docker containers. 229 | 230 | Options:`) 231 | flag.PrintDefaults() 232 | fmt.Println() 233 | fmt.Println(`Example: Generate /etc/nginx/nginx.conf using nginx.tmpl as a template, tail nginx logs, wait for a website to become available on port 8000 and then start nginx.`) 234 | fmt.Println(` 235 | dockerize -template nginx.tmpl:/etc/nginx/nginx.conf \ 236 | -stdout /var/log/nginx/access.log \ 237 | -stderr /var/log/nginx/error.log \ 238 | -wait tcp://web:8000 \ 239 | nginx -g 'daemon off;' 240 | `) 241 | fmt.Println(`For more information, see https://github.com/powerman/dockerize`) 242 | } 243 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "strconv" 12 | "strings" 13 | "syscall" 14 | "testing" 15 | "time" 16 | 17 | "github.com/powerman/check" 18 | "github.com/powerman/gotest/testexec" 19 | ) 20 | 21 | func TestFlagHelp(tt *testing.T) { 22 | t := check.T(tt) 23 | t.Parallel() 24 | out, err := testexec.Func(testCtx, t, main, "-h").CombinedOutput() 25 | t.Nil(err) 26 | t.Match(out, "Usage:") 27 | } 28 | 29 | func TestFlagVersion(tt *testing.T) { 30 | t := check.T(tt) 31 | t.Parallel() 32 | out, err := testexec.Func(testCtx, t, main, "-version").CombinedOutput() 33 | t.Nil(err) 34 | t.Match(out, ver) 35 | } 36 | 37 | func TestFlag(tt *testing.T) { 38 | t := check.T(tt) 39 | t.Parallel() 40 | cases := []struct { 41 | flags []string 42 | want string 43 | }{ 44 | {[]string{"-env", "file:///dev/null"}, `http/https`}, 45 | {[]string{"-env", "/dev/null"}, ``}, 46 | {[]string{"-env", "http://file.ini"}, ``}, 47 | {[]string{"-env", "https://file.ini"}, ``}, 48 | {[]string{"-env-header", ""}, `name:value`}, 49 | {[]string{"-env-header", "bad"}, `name:value`}, 50 | {[]string{"-env-header", " : "}, `name:value`}, 51 | {[]string{"-env-header", " name : "}, `name:value`}, 52 | {[]string{"-env-header", " : value "}, `name:value`}, 53 | {[]string{"-env-header", "n:v", "-env-header", ":", "-env-header", "n:v"}, `name:value`}, 54 | {[]string{"-env-header", " name : some value "}, `-env with HTTP`}, 55 | {[]string{"-env-header", "n:v", "-env", "/dev/null"}, `-env with HTTP`}, 56 | {[]string{"-template", ""}, `src:dst or src`}, 57 | {[]string{"-template", "a:b:c"}, `src:dst or src`}, 58 | {[]string{"-template", ":"}, `src:dst or src`}, 59 | {[]string{"-template", ":b"}, `src:dst or src`}, 60 | {[]string{"-template", " "}, ``}, 61 | {[]string{"-template", "a", "-template", "a:", "-template", "a:b"}, ``}, 62 | {[]string{"-no-overwrite"}, `-template`}, 63 | {[]string{"-no-overwrite", "-template", "a"}, ``}, 64 | {[]string{"-template-strict"}, `-template`}, 65 | {[]string{"-template-strict", "-template", "a"}, ``}, 66 | {[]string{"-delims", ""}, `left:right`}, 67 | {[]string{"-delims", ":"}, `left:right`}, 68 | {[]string{"-delims", "a:"}, `left:right`}, 69 | {[]string{"-delims", ":b"}, `left:right`}, 70 | {[]string{"-delims", "a a:b"}, `left:right`}, 71 | {[]string{"-delims", "a:b"}, `-template`}, 72 | {[]string{"-delims", " a: b ", "-template", "a"}, ``}, 73 | {[]string{"-wait", ""}, `file/tcp/tcp4/tcp6/unix/http/https/amqp/amqps`}, 74 | {[]string{"-wait", "/dev/null"}, `file/tcp/tcp4/tcp6/unix/http/https/amqp/amqps`}, 75 | {[]string{"-wait", "file:///dev/null", "-wait", "http:", "-wait", "https:"}, ``}, 76 | {[]string{"-wait", "tcp:", "-wait", "tcp4:", "-wait", "tcp6:", "-wait", "unix:"}, ``}, 77 | {[]string{"-wait-list", "tcp: tcp4: http: https: unix: file:"}, ``}, 78 | {[]string{"-wait-http-header", ""}, `name:value`}, 79 | {[]string{"-wait-http-header", "a:b"}, `-wait with HTTP`}, 80 | {[]string{"-wait-http-header", "a:b", "-wait", "unix:"}, `-wait with HTTP`}, 81 | {[]string{"-wait-http-header", "a:b", "-wait", "http:"}, ``}, 82 | {[]string{"-wait-http-header", "a:b", "-wait", "https:"}, ``}, 83 | {[]string{"-skip-tls-verify"}, `-wait/-env`}, 84 | {[]string{"-skip-tls-verify", "-wait", "unix:"}, `-wait/-env`}, 85 | {[]string{"-skip-tls-verify", "-env", "http:"}, ``}, 86 | {[]string{"-skip-tls-verify", "-wait", "http:"}, ``}, 87 | {[]string{"-cacert", "/dev/null"}, `-wait/-env`}, 88 | {[]string{"-cacert", "/dev/null", "-wait", "unix:"}, `-wait/-env`}, 89 | {[]string{"-cacert", "/dev/null", "-env", "http:"}, ``}, 90 | {[]string{"-cacert", "/dev/null", "-wait", "http:"}, ``}, 91 | {[]string{"-wait-http-skip-redirect"}, `-wait with HTTP`}, 92 | {[]string{"-wait-http-skip-redirect", "-wait", "unix:"}, `-wait with HTTP`}, 93 | {[]string{"-wait-http-skip-redirect", "-wait", "http:"}, ``}, 94 | {[]string{"-wait-http-status-code", ""}, `syntax`}, 95 | {[]string{"-wait-http-status-code", "99"}, `between 100 and 599`}, 96 | {[]string{"-wait-http-status-code", "600"}, `between 100 and 599`}, 97 | {[]string{"-wait-http-status-code", "200"}, `-wait with HTTP`}, 98 | {[]string{"-wait-http-status-code", "200", "-wait-http-status-code", "201", "-wait", "http:"}, ``}, 99 | {[]string{"-timeout", "1s"}, ``}, 100 | {[]string{"-wait-retry-interval", "1s"}, ``}, 101 | {[]string{"-stdout", "", "-stdout", " ", "-stderr", " "}, ``}, 102 | {[]string{"-exec"}, `require command to exec`}, 103 | {[]string{"-exec", "-stdout", "/dev/null"}, `not supported`}, 104 | {[]string{"-exec", "-stderr", "/dev/null"}, `not supported`}, 105 | } 106 | for _, v := range cases { 107 | t.Run(strings.Join(v.flags, " "), func(tt *testing.T) { 108 | t := check.T(tt) 109 | t.Parallel() 110 | flags := append(v.flags, "-version") //nolint:gocritic // By design. 111 | out, err := testexec.Func(testCtx, t, main, flags...).CombinedOutput() 112 | if v.want == "" { 113 | t.Nil(err) 114 | t.Match(out, ver) 115 | } else { 116 | t.Match(err, "exit status 2") 117 | t.Match(out, `invalid value .* `+v.flags[0]+`:.*`+v.want) 118 | } 119 | }) 120 | } 121 | } 122 | 123 | func TestFailedINI(tt *testing.T) { 124 | t := check.T(tt) 125 | t.Parallel() 126 | out, err := testexec.Func(testCtx, t, main, "-exit-code", "42", "-env", "nosuch.ini").CombinedOutput() 127 | t.Match(err, "exit status 42") 128 | t.Match(out, `nosuch.ini: no such file`) 129 | } 130 | 131 | func TestFailedTemplate(tt *testing.T) { 132 | t := check.T(tt) 133 | t.Parallel() 134 | out, err := testexec.Func(testCtx, t, main, "-template", "nosuch.tmpl").CombinedOutput() 135 | t.Match(err, "exit status 123") 136 | t.Match(out, `nosuch.tmpl: no such file`) 137 | } 138 | 139 | func TestFailedStrictTemplate(tt *testing.T) { 140 | t := check.T(tt) 141 | t.Parallel() 142 | out, err := testexec.Func(testCtx, t, main, "-template", "testdata/src1.tmpl", "-template-strict").CombinedOutput() 143 | t.Match(err, "exit status 123") 144 | t.Match(out, `no entry for key "C"`) 145 | } 146 | 147 | func TestFailedWait(tt *testing.T) { 148 | t := check.T(tt) 149 | t.Parallel() 150 | out, err := testexec.Func(testCtx, t, main, "-wait", "file:///nosuch", "-timeout", "0.1s").CombinedOutput() 151 | t.Match(err, "exit status 123") 152 | t.Match(out, `/nosuch: no such file`) 153 | } 154 | 155 | func TestNothing(tt *testing.T) { 156 | t := check.T(tt) 157 | t.Parallel() 158 | out, err := testexec.Func(testCtx, t, main).CombinedOutput() 159 | t.Nil(err) 160 | t.Match(out, `^$`) 161 | } 162 | 163 | func TestTail(tt *testing.T) { 164 | t := checkT(tt) 165 | t.Parallel() 166 | 167 | var logf [4]*os.File 168 | var logn [4]string 169 | if os.Getenv("GO_WANT_HELPER_PROCESS") == "" { // don't do this again in subprocess 170 | for i := range logf { 171 | logf[i] = t.NoErrFile(os.CreateTemp("", "gotest")) 172 | logn[i] = logf[i].Name() 173 | defer os.Remove(logn[i]) //nolint:gocritic,revive // By design. 174 | defer logf[i].Close() //nolint:gocritic,revive // By design. 175 | } 176 | } 177 | 178 | cmd := testexec.Func(testCtx, t, main, 179 | "-stdout", logn[0], "-stdout", logn[1], 180 | "-stderr", logn[2], "-stderr", logn[3], 181 | ) 182 | cmd.Stdout = &bytes.Buffer{} 183 | cmd.Stderr = &bytes.Buffer{} 184 | t.Nil(cmd.Start()) 185 | 186 | time.Sleep(testSecond) 187 | for i := range logf { 188 | t.NoErr(fmt.Fprintf(logf[i], "log%d\n", i)) 189 | } 190 | time.Sleep(testSecond) 191 | 192 | t.Nil(cmd.Process.Kill()) 193 | t.Match(cmd.Wait(), `signal: killed`) 194 | stdout := cmd.Stdout.(*bytes.Buffer).String() 195 | stderr := cmd.Stderr.(*bytes.Buffer).String() 196 | t.Match(stdout, `(?m)^log0$`) 197 | t.Match(stdout, `(?m)^log1$`) 198 | t.Match(stderr, `(?m)^log2$`) 199 | t.Match(stderr, `(?m)^log3$`) 200 | } 201 | 202 | func TestWaitList(tt *testing.T) { 203 | t := checkT(tt) 204 | t.Parallel() 205 | 206 | var logn, filen, unixn string 207 | if os.Getenv("GO_WANT_HELPER_PROCESS") == "" { // don't do this again in subprocess 208 | logf := t.NoErrFile(os.CreateTemp("", "gotest")) 209 | logn = logf.Name() 210 | defer os.Remove(logn) 211 | defer logf.Close() 212 | filen = t.TempPath() 213 | unixn = t.TempPath() 214 | } 215 | 216 | lnTCP := t.NoErrListen(net.Listen("tcp", "127.0.0.1:0")) 217 | t.Nil(lnTCP.Close()) 218 | lnTCP4 := t.NoErrListen(net.Listen("tcp4", "127.0.0.1:0")) 219 | t.Nil(lnTCP4.Close()) 220 | mux := http.NewServeMux() 221 | ts := httptest.NewUnstartedServer(mux) 222 | defer ts.Close() 223 | 224 | cmd := testexec.Func(testCtx, t, main, 225 | "-env", "testdata/env1.ini", 226 | "-template", "testdata/src1.tmpl", 227 | "-no-overwrite", 228 | "-wait", "file://"+filen, 229 | "-wait-list", "tcp://"+lnTCP.Addr().String()+" tcp4://"+lnTCP4.Addr().String()+" unix://"+unixn, 230 | "-wait", "http://"+ts.Listener.Addr().String()+"/redirect", 231 | "-timeout", testSecond.String(), 232 | "-wait-retry-interval", (testSecond / 10).String(), 233 | "-stderr", logn, 234 | "sh", "-c", "sleep 1; exit 42", 235 | ) 236 | cmd.Stdout = &bytes.Buffer{} 237 | cmd.Stderr = &bytes.Buffer{} 238 | t.Nil(cmd.Start()) 239 | 240 | time.Sleep(testSecond / 2) 241 | t.Nil(t.NoErrFile(os.Create(filen)).Close()) 242 | defer os.Remove(filen) 243 | lnUnix := t.NoErrListen(net.Listen("unix", unixn)) 244 | defer lnUnix.Close() 245 | lnTCP = t.NoErrListen(net.Listen("tcp", lnTCP.Addr().String())) 246 | defer lnTCP.Close() 247 | lnTCP4 = t.NoErrListen(net.Listen("tcp4", lnTCP4.Addr().String())) 248 | defer lnTCP4.Close() 249 | var callOK bool 250 | mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { 251 | http.Redirect(w, r, "/ok", http.StatusFound) 252 | }) 253 | mux.HandleFunc("/ok", func(_ http.ResponseWriter, _ *http.Request) { 254 | callOK = true 255 | }) 256 | ts.Start() 257 | 258 | t.Match(cmd.Wait(), `exit status 42`) 259 | stdout := cmd.Stdout.(*bytes.Buffer).String() 260 | stderr := cmd.Stderr.(*bytes.Buffer).String() 261 | t.Equal(stdout, "A=10 B=20 C=31\n") 262 | t.Contains(stderr, "Ready:") 263 | 264 | t.True(callOK) 265 | } 266 | 267 | func TestSmoke1(tt *testing.T) { 268 | t := checkT(tt) 269 | t.Parallel() 270 | 271 | var logn, filen, unixn string 272 | if os.Getenv("GO_WANT_HELPER_PROCESS") == "" { // don't do this again in subprocess 273 | logf := t.NoErrFile(os.CreateTemp("", "gotest")) 274 | logn = logf.Name() 275 | defer os.Remove(logn) 276 | defer logf.Close() 277 | filen = t.TempPath() 278 | unixn = t.TempPath() 279 | } 280 | 281 | lnTCP := t.NoErrListen(net.Listen("tcp", "127.0.0.1:0")) 282 | t.Nil(lnTCP.Close()) 283 | lnTCP4 := t.NoErrListen(net.Listen("tcp4", "127.0.0.1:0")) 284 | t.Nil(lnTCP4.Close()) 285 | mux := http.NewServeMux() 286 | ts := httptest.NewUnstartedServer(mux) 287 | defer ts.Close() 288 | 289 | cmd := testexec.Func(testCtx, t, main, 290 | "-env", "testdata/env1.ini", 291 | "-template", "testdata/src1.tmpl", 292 | "-no-overwrite", 293 | "-wait", "file://"+filen, 294 | "-wait", "tcp://"+lnTCP.Addr().String(), 295 | "-wait", "tcp4://"+lnTCP4.Addr().String(), 296 | "-wait", "unix://"+unixn, 297 | "-wait", "http://"+ts.Listener.Addr().String()+"/redirect", 298 | "-timeout", testSecond.String(), 299 | "-wait-retry-interval", (testSecond / 10).String(), 300 | "-stderr", logn, 301 | "sh", "-c", "sleep 1; exit 42", 302 | ) 303 | cmd.Stdout = &bytes.Buffer{} 304 | cmd.Stderr = &bytes.Buffer{} 305 | t.Nil(cmd.Start()) 306 | 307 | time.Sleep(testSecond / 2) 308 | t.Nil(t.NoErrFile(os.Create(filen)).Close()) 309 | defer os.Remove(filen) 310 | lnUnix := t.NoErrListen(net.Listen("unix", unixn)) 311 | defer lnUnix.Close() 312 | lnTCP = t.NoErrListen(net.Listen("tcp", lnTCP.Addr().String())) 313 | defer lnTCP.Close() 314 | lnTCP4 = t.NoErrListen(net.Listen("tcp4", lnTCP4.Addr().String())) 315 | defer lnTCP4.Close() 316 | var callOK bool 317 | mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { 318 | http.Redirect(w, r, "/ok", http.StatusFound) 319 | }) 320 | mux.HandleFunc("/ok", func(_ http.ResponseWriter, _ *http.Request) { 321 | callOK = true 322 | }) 323 | ts.Start() 324 | 325 | t.Match(cmd.Wait(), `exit status 42`) 326 | stdout := cmd.Stdout.(*bytes.Buffer).String() 327 | stderr := cmd.Stderr.(*bytes.Buffer).String() 328 | t.Equal(stdout, "A=10 B=20 C=31\n") 329 | t.Contains(stderr, "Ready:") 330 | 331 | t.True(callOK) 332 | } 333 | 334 | func TestSmoke2(tt *testing.T) { 335 | t := checkT(tt) 336 | t.Parallel() 337 | 338 | dstDir := t.TempPath() 339 | if strings.Contains(dstDir, "/gotest") { // protect in case of bug in TempPath 340 | defer os.RemoveAll(dstDir) 341 | } 342 | mux := http.NewServeMux() 343 | ts := httptest.NewUnstartedServer(mux) 344 | defer ts.Close() 345 | 346 | cmd := testexec.Func(testCtx, t, main, 347 | "-env", "https://"+ts.Listener.Addr().String()+"/ini", 348 | "-multiline", 349 | "-env-section", "Vars", 350 | "-env-header", "User: env", 351 | "-env-header", "testdata/secret.hdr", 352 | "-template", "testdata/src2:"+dstDir, 353 | "-delims", "<<:>>", 354 | "-wait", "https://"+ts.Listener.Addr().String()+"/redirect", 355 | "-wait-http-header", "User: wait", 356 | "-wait-http-header", "testdata/secret.hdr", 357 | "-skip-tls-verify", 358 | "-wait-http-skip-redirect", 359 | "-wait-http-status-code", "302", 360 | "-wait-http-status-code", "307", 361 | "-timeout", testSecond.String(), 362 | "-wait-retry-interval", (testSecond / 10).String(), 363 | "sh", "-c", ` 364 | exec /dev/null 365 | echo $$ 366 | trap '' HUP INT QUIT ABRT ALRM TERM 367 | trap 'echo USR; exec >/dev/null' USR1 USR2 368 | sleep 10 >/dev/null & 369 | while ! wait; do :; done 370 | exit 42 371 | `, 372 | ) 373 | cmd.Stdout = &bytes.Buffer{} 374 | cmd.Stderr = &bytes.Buffer{} 375 | t.Nil(cmd.Start()) 376 | 377 | time.Sleep(testSecond / 2) 378 | var callINI, callRedirect bool 379 | mux.HandleFunc("/ini", func(w http.ResponseWriter, r *http.Request) { 380 | callINI = true 381 | t.Equal(r.Header.Get("User"), "env") 382 | t.Equal(r.Header.Get("Pass"), "Secret") 383 | f, err := os.Open("testdata/env2.ini") 384 | t.Nil(err) 385 | t.NoErr(io.Copy(w, f)) 386 | t.Nil(f.Close()) 387 | }) 388 | mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { 389 | callRedirect = true 390 | t.Equal(r.Header.Get("User"), "wait") 391 | t.Equal(r.Header.Get("Pass"), "Secret") 392 | http.Redirect(w, r, "/nosuch", http.StatusFound) 393 | }) 394 | ts.StartTLS() 395 | 396 | time.Sleep(testSecond) 397 | t.Nil(cmd.Process.Signal(syscall.SIGUSR1)) 398 | t.Nil(cmd.Process.Signal(syscall.SIGTERM)) 399 | time.Sleep(testSecond) 400 | t.Nil(cmd.Process.Kill()) 401 | 402 | t.Match(cmd.Wait(), `signal: killed`) 403 | stdout := cmd.Stdout.(*bytes.Buffer).String() 404 | stderr := cmd.Stderr.(*bytes.Buffer).String() 405 | t.Log(stderr) 406 | parts := strings.SplitN(stdout, "\n", 2) 407 | t.Must(t.Len(parts, 2)) 408 | 409 | childPID := t.NoErrInt(strconv.Atoi(parts[0])) 410 | child, err := os.FindProcess(childPID) 411 | t.Nil(err) 412 | time.Sleep(testSecond / 2) // wait for OS cleanup after forwarding SIGKILL to child on Linux 413 | err = child.Kill() 414 | if err != nil { 415 | t.Match(err, `process already finished`) 416 | } 417 | t.Nil(child.Release()) 418 | 419 | t.Equal(parts[1], "USR\n") 420 | 421 | t.True(callINI) 422 | t.True(callRedirect) 423 | 424 | buf := t.NoErrBuf(os.ReadFile(dstDir + "/abc")) 425 | t.Equal(string(buf), "A=10 B=20 C=32\n 777\n") 426 | buf = t.NoErrBuf(os.ReadFile(dstDir + "/subdir/func")) 427 | t.Equal(string(buf), "abc exists\nexample.com\nTrue!False!\nJSON value\n0369\n") 428 | } 429 | -------------------------------------------------------------------------------- /scripts/cover: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export PATH="$PWD/.buildcache/bin:$PATH" 3 | set -e -o pipefail 4 | go generate 0-tools.go 5 | 6 | gotestsum -- \ 7 | -coverpkg="$(go list ./... | paste -s -d,)" \ 8 | -coverprofile .buildcache/cover.out \ 9 | "$@" ./... 10 | 11 | go tool cover -func=.buildcache/cover.out | tail -n 1 | xargs echo 12 | 13 | test -n "$CI" || go tool cover -html=.buildcache/cover.out 14 | -------------------------------------------------------------------------------- /scripts/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export PATH="$PWD/.buildcache/bin:$PATH" 3 | set -x -e -o pipefail 4 | go generate 0-tools.go 5 | 6 | DIST_DIR=bin 7 | TAG=$(git describe --match='v*' --exact-match --tags) 8 | GH="$(git remote get-url origin | sed -e 's/.*://' -e 's/\.git$//')" 9 | GH_USER="${GH%%/*}" 10 | GH_REPO="${GH##*/}" 11 | 12 | mkdir -p $DIST_DIR 13 | while read -r suffix GOOS GOARCH GOARM; do 14 | CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM \ 15 | go build -o "$DIST_DIR/${GH_REPO}-$suffix" -ldflags "-s -w -X 'main.ver=$TAG'" 16 | pushd "$DIST_DIR" 17 | upx "${GH_REPO}-$suffix" 18 | sha256sum "${GH_REPO}-$suffix" >"${GH_REPO}-$suffix.sha256" 19 | #gpg --armor --output "${GH_REPO}-$suffix.asc" --detach-sign "${GH_REPO}-$suffix" 20 | popd 21 | done <> B=<< .Env.B >> C=<< .Env.C >> 2 | -------------------------------------------------------------------------------- /testdata/src2/subdir/func: -------------------------------------------------------------------------------- 1 | << if exists "testdata/src2/abc" >>abc exists<< end >> 2 | << (parseUrl "http://example.com/path").Host >> 3 | << if isTrue "TRuE" >>True!<><< if not (isTrue "Yes") >>False!<< end >> 4 | << jsonQuery `[null,{"key":"JSON value"}]` "[1].key" >> 5 | << range untilStep 0 10 3 >><< . >><< end >> 6 | -------------------------------------------------------------------------------- /tls.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/x509" 5 | "errors" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | var errPEM = errors.New("unable to load PEM certs") 11 | 12 | // LoadCACert returns a new CertPool with certificates loaded from given path. 13 | func LoadCACert(path string) (*x509.CertPool, error) { 14 | if path == "" { 15 | return nil, nil //nolint:nilnil // TODO. 16 | } 17 | ca, err := x509.SystemCertPool() 18 | if err != nil { 19 | return nil, err 20 | } 21 | caCert, err := os.ReadFile(path) //nolint:gosec // False positive. 22 | if err == nil && !ca.AppendCertsFromPEM(caCert) { 23 | err = fmt.Errorf("%w: %q", errPEM, path) 24 | } 25 | if err != nil { 26 | return nil, err 27 | } 28 | return ca, nil 29 | } 30 | -------------------------------------------------------------------------------- /wait.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "time" 16 | 17 | "github.com/streadway/amqp" 18 | ) 19 | 20 | var ( 21 | errUnexpectedStatusCode = errors.New("unexpected HTTP status code") 22 | errSchemeNotSupported = errors.New("wait scheme not supported") 23 | errTimedOut = errors.New("timed out") 24 | ) 25 | 26 | type waitConfig struct { 27 | headers httpHeadersFlag 28 | skipTLSVerify bool 29 | ca *x509.CertPool 30 | skipRedirect bool 31 | statusCodes statusCodesFlag 32 | timeout time.Duration 33 | delay time.Duration 34 | } 35 | 36 | func waitForURLs(cfg waitConfig, urls []*url.URL) error { 37 | ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout) 38 | defer cancel() 39 | 40 | waiting := make(map[*url.URL]bool, len(urls)) 41 | readyc := make(chan *url.URL, len(urls)) 42 | for _, u := range urls { 43 | if !waiting[u] { // skip possible duplicates 44 | waiting[u] = true 45 | switch u.Scheme { 46 | case schemeFile: 47 | go waitForPath(ctx, cfg, u, readyc) 48 | case schemeTCP, schemeTCP4, schemeTCP6, schemeUnix: 49 | go waitForSocket(ctx, cfg, u, readyc) 50 | case schemeHTTP, schemeHTTPS: 51 | go waitForHTTP(ctx, cfg, u, readyc) 52 | case schemeAMQP, schemeAMQPS: 53 | go waitForAMQP(ctx, cfg, u, readyc) 54 | default: 55 | return fmt.Errorf("%w: %s", errSchemeNotSupported, u) 56 | } 57 | } 58 | } 59 | 60 | for len(waiting) > 0 { 61 | select { 62 | case u := <-readyc: 63 | log.Printf("Ready: %s.", u) 64 | delete(waiting, u) 65 | case <-ctx.Done(): 66 | for s := range waiting { 67 | return fmt.Errorf("%w: %s", errTimedOut, s) 68 | } 69 | panic("internal error") 70 | } 71 | } 72 | return nil 73 | } 74 | 75 | func waitForPath(ctx context.Context, cfg waitConfig, u *url.URL, readyc chan<- *url.URL) { 76 | for { 77 | _, err := os.Stat(u.Path) 78 | if err == nil { 79 | break 80 | } 81 | log.Printf("Waiting for %s: %s.", u, err) 82 | select { 83 | case <-time.After(cfg.delay): 84 | case <-ctx.Done(): 85 | return 86 | } 87 | } 88 | 89 | readyc <- u 90 | } 91 | 92 | func waitForSocket(ctx context.Context, cfg waitConfig, u *url.URL, readyc chan<- *url.URL) { 93 | addr := u.Host 94 | if u.Scheme == schemeUnix { 95 | addr = u.Path 96 | } 97 | dialer := &net.Dialer{} 98 | 99 | for { 100 | conn, err := dialer.DialContext(ctx, u.Scheme, addr) 101 | if err == nil { 102 | warnIfFail(conn.Close) 103 | break 104 | } 105 | log.Printf("Waiting for %s: %s.", u, err) 106 | select { 107 | case <-time.After(cfg.delay): 108 | case <-ctx.Done(): 109 | return 110 | } 111 | } 112 | 113 | readyc <- u 114 | } 115 | 116 | func waitForHTTP(ctx context.Context, cfg waitConfig, u *url.URL, readyc chan<- *url.URL) { //nolint:interfacer // False positive. 117 | waitStatusCode := make(map[int]bool) 118 | if len(cfg.statusCodes) == 0 { 119 | for statusCode := http.StatusOK; statusCode < http.StatusMultipleChoices; statusCode++ { 120 | waitStatusCode[statusCode] = true 121 | } 122 | } else { 123 | for _, statusCode := range cfg.statusCodes { 124 | waitStatusCode[statusCode] = true 125 | } 126 | } 127 | 128 | client := &http.Client{ 129 | Transport: &http.Transport{ 130 | Proxy: http.ProxyFromEnvironment, 131 | TLSClientConfig: &tls.Config{ 132 | InsecureSkipVerify: cfg.skipTLSVerify, //nolint:gosec // TLS InsecureSkipVerify may be true. 133 | RootCAs: cfg.ca, 134 | }, 135 | }, 136 | } 137 | if cfg.skipRedirect { 138 | client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { 139 | return http.ErrUseLastResponse 140 | } 141 | } 142 | var resp *http.Response 143 | 144 | for { 145 | req, err := http.NewRequest(http.MethodGet, u.String(), http.NoBody) 146 | if err == nil { 147 | for _, h := range cfg.headers { 148 | req.Header.Add(h.name, h.value) 149 | } 150 | resp, err = client.Do(req.WithContext(ctx)) //nolint:bodyclose // False positive. 151 | } 152 | if err == nil { 153 | _, _ = io.Copy(io.Discard, resp.Body) 154 | _ = resp.Body.Close() 155 | if waitStatusCode[resp.StatusCode] { 156 | break 157 | } 158 | err = fmt.Errorf("%w: %d", errUnexpectedStatusCode, resp.StatusCode) 159 | } 160 | log.Printf("Waiting for %s: %s.", u, err) 161 | select { 162 | case <-time.After(cfg.delay): 163 | case <-ctx.Done(): 164 | return 165 | } 166 | } 167 | 168 | readyc <- u 169 | } 170 | 171 | func waitForAMQP(ctx context.Context, cfg waitConfig, u *url.URL, readyc chan<- *url.URL) { //nolint:interfacer // False positive. 172 | amqpCfg := amqp.Config{ 173 | TLSClientConfig: &tls.Config{ 174 | InsecureSkipVerify: cfg.skipTLSVerify, //nolint:gosec // TLS InsecureSkipVerify may be true. 175 | RootCAs: cfg.ca, 176 | }, 177 | } 178 | 179 | for { 180 | if deadline, ok := ctx.Deadline(); ok { 181 | amqpCfg.Dial = amqp.DefaultDial(time.Until(deadline)) 182 | } 183 | conn, err := amqp.DialConfig(u.String(), amqpCfg) 184 | if err == nil { 185 | _, err = conn.Channel() 186 | _ = conn.Close() 187 | } 188 | if err == nil { 189 | break 190 | } 191 | 192 | log.Printf("Waiting for %s: %s.", u, err) 193 | select { 194 | case <-time.After(cfg.delay): 195 | case <-ctx.Done(): 196 | return 197 | } 198 | } 199 | 200 | readyc <- u 201 | } 202 | --------------------------------------------------------------------------------