Skip to content

Commit b9b4fb3

Browse files
committed
init: initial commit
0 parents  commit b9b4fb3

File tree

17 files changed

+1270
-0
lines changed

17 files changed

+1270
-0
lines changed

.github/workflows/build.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: Build
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
include:
15+
- goos: darwin
16+
goarch: arm64
17+
suffix: darwin-arm64
18+
- goos: linux
19+
goarch: amd64
20+
suffix: linux-amd64
21+
- goos: windows
22+
goarch: amd64
23+
suffix: windows-amd64.exe
24+
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- uses: actions/setup-go@v5
29+
with:
30+
go-version: "1.24"
31+
32+
- name: Build
33+
env:
34+
GOOS: ${{ matrix.goos }}
35+
GOARCH: ${{ matrix.goarch }}
36+
run: go build -o http-pull-${{ matrix.suffix }} ./cmd/http-pull/
37+
38+
- name: Upload artifact
39+
uses: actions/upload-artifact@v4
40+
with:
41+
name: http-pull-${{ matrix.suffix }}
42+
path: http-pull-${{ matrix.suffix }}
43+
44+
package-deb:
45+
runs-on: ubuntu-latest
46+
needs: build
47+
strategy:
48+
matrix:
49+
include:
50+
- arch: amd64
51+
artifact: http-pull-linux-amd64
52+
53+
steps:
54+
- uses: actions/checkout@v4
55+
56+
- name: Download binary
57+
uses: actions/download-artifact@v4
58+
with:
59+
name: ${{ matrix.artifact }}
60+
61+
- name: Build .deb package
62+
run: |
63+
chmod +x ${{ matrix.artifact }}
64+
bash packaging/build-deb.sh
65+
env:
66+
VERSION: 0.1.0
67+
ARCH: ${{ matrix.arch }}
68+
BINARY: ${{ matrix.artifact }}
69+
70+
- name: Upload .deb package
71+
uses: actions/upload-artifact@v4
72+
with:
73+
name: http-pull_${{ matrix.arch }}.deb
74+
path: "*.deb"

.gitignore

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Binaries
2+
./http-pull
3+
*.exe
4+
5+
# Test binary
6+
*.test
7+
8+
# Coverage
9+
*.out
10+
11+
# IDE
12+
.idea/
13+
.vscode/
14+
15+
# OS
16+
.DS_Store
17+
18+
# AI Tools
19+
.claude/
20+
CLAUDE.md

README.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# http-pull
2+
3+
A lightweight Go service that periodically downloads files via HTTP(S), stores them locally, and runs configurable hooks afterward.
4+
5+
## Features
6+
7+
- **Scheduled HTTP pulls** — download files at configurable intervals per target
8+
- **Atomic writes** — files are written to a temp file first, then renamed into place
9+
- **Built-in hooks** — run shell commands or move files after each download
10+
- **Extensible hook interface** — third-party hooks via a public Go interface in `pkg/hook`
11+
- **Live reload** — send `SIGHUP` to re-read configuration without restarting
12+
- **Graceful shutdown** — in-flight downloads and hooks complete before exit
13+
- **Structured logging** — JSON logs via `slog`, configurable level and output target
14+
15+
## Installation
16+
17+
Requires Go 1.24+.
18+
19+
```sh
20+
go build -o http-pull ./cmd/http-pull/
21+
```
22+
23+
## Usage
24+
25+
```sh
26+
http-pull --config /path/to/config.yaml
27+
```
28+
29+
| Flag | Default | Description |
30+
|------|---------|-------------|
31+
| `--config` | `/etc/http-pull/config.yaml` | Path to the configuration file |
32+
33+
### Signals
34+
35+
| Signal | Behaviour |
36+
|--------|-----------|
37+
| `SIGHUP` | Reload configuration and apply changes live |
38+
| `SIGINT` / `SIGTERM` | Graceful shutdown (30s timeout for in-flight work) |
39+
40+
## Configuration
41+
42+
```yaml
43+
log:
44+
level: INFO # DEBUG, INFO, WARN, or ERROR
45+
target: stdout # stdout or file
46+
file: /var/log/http-pull.log # only used when target is "file"
47+
48+
targets:
49+
- name: example
50+
url: https://example.com/data.txt
51+
interval: 30s
52+
destination: /tmp/data.txt
53+
http_request: # optional
54+
method: GET # default: GET
55+
headers: # optional
56+
- name: Authorization
57+
value: Bearer my-token
58+
basic_auth: # optional
59+
username: user
60+
password: pass
61+
follow_redirects: true # default: true
62+
hooks: # optional
63+
- type: shell
64+
command: echo "Downloaded successfully"
65+
on: # default: [success]
66+
- success
67+
- failure
68+
- type: move
69+
destination: /opt/data/data.txt
70+
```
71+
72+
### Defaults
73+
74+
| Setting | Default |
75+
|---------|---------|
76+
| `log.level` | `INFO` |
77+
| `log.target` | `stdout` |
78+
| `http_request.method` | `GET` |
79+
| `http_request.follow_redirects` | `true` |
80+
| `hooks[].on` | `["success"]` |
81+
| `User-Agent` header | `http-pull/1.0` (overridable via `headers`) |
82+
83+
## Hooks
84+
85+
### Built-in hooks
86+
87+
**`shell`** — Executes a command via `/bin/sh -c` after the pull completes.
88+
89+
```yaml
90+
- type: shell
91+
command: systemctl reload nginx
92+
on: [success]
93+
```
94+
95+
**`move`** — Moves the downloaded file to another path. Uses atomic rename when possible, falls back to copy+delete for cross-device moves.
96+
97+
```yaml
98+
- type: move
99+
destination: /etc/app/config.json
100+
on: [success]
101+
```
102+
103+
### Hook triggers
104+
105+
| Value | Description |
106+
|-------|-------------|
107+
| `success` | Run when the download succeeds (HTTP status < 400) |
108+
| `failure` | Run when the download fails (HTTP status >= 400 or network error) |
109+
110+
### Writing custom hooks
111+
112+
The hook interface is in `pkg/hook` so it can be imported by external projects:
113+
114+
```go
115+
import "http-pull/pkg/hook"
116+
117+
type MyHook struct{}
118+
119+
func (h *MyHook) Execute(ctx context.Context, result hook.Result) error {
120+
if result.Success {
121+
// handle successful download at result.FilePath
122+
}
123+
return nil
124+
}
125+
```
126+
127+
## Project structure
128+
129+
```
130+
cmd/http-pull/ Entry point and signal handling
131+
internal/
132+
config/ Configuration loading and validation (Viper)
133+
logging/ Structured logging setup (slog)
134+
hook/ Built-in hook implementations and registry
135+
puller/ HTTP download with atomic file writes
136+
runner/ Worker orchestration, reload, and graceful shutdown
137+
pkg/hook/ Public hook interface for third-party extensions
138+
```
139+
140+
## License
141+
142+
See [LICENSE](LICENSE) for details.

cmd/http-pull/main.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/signal"
8+
"syscall"
9+
"time"
10+
11+
"github.com/spf13/pflag"
12+
13+
"http-pull/internal/config"
14+
"http-pull/internal/logging"
15+
"http-pull/internal/runner"
16+
)
17+
18+
func main() {
19+
configPath := pflag.String("config", "/etc/http-pull/config.yaml", "path to configuration file")
20+
pflag.Parse()
21+
22+
cfg, err := config.Load(*configPath)
23+
if err != nil {
24+
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
25+
os.Exit(1)
26+
}
27+
28+
logger, logCleanup, err := logging.Setup(cfg.Log)
29+
if err != nil {
30+
fmt.Fprintf(os.Stderr, "error setting up logging: %v\n", err)
31+
os.Exit(1)
32+
}
33+
defer logCleanup()
34+
35+
logger.Info("http-pull starting", "config", *configPath)
36+
37+
r := runner.New(logger)
38+
if err := r.ApplyConfig(cfg); err != nil {
39+
logger.Error("error applying config", "error", err)
40+
os.Exit(1)
41+
}
42+
43+
sigCh := make(chan os.Signal, 1)
44+
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
45+
46+
for sig := range sigCh {
47+
switch sig {
48+
case syscall.SIGHUP:
49+
logger.Info("received SIGHUP, reloading configuration")
50+
newCfg, err := config.Load(*configPath)
51+
if err != nil {
52+
logger.Error("failed to reload config, keeping current", "error", err)
53+
continue
54+
}
55+
56+
newLogger, newCleanup, err := logging.Setup(newCfg.Log)
57+
if err != nil {
58+
logger.Error("failed to setup new logging, keeping current", "error", err)
59+
continue
60+
}
61+
62+
logCleanup()
63+
logCleanup = newCleanup
64+
logger = newLogger
65+
r.SetLogger(logger)
66+
67+
if err := r.ApplyConfig(newCfg); err != nil {
68+
logger.Error("failed to apply new config", "error", err)
69+
} else {
70+
logger.Info("configuration reloaded successfully")
71+
}
72+
73+
case syscall.SIGINT, syscall.SIGTERM:
74+
logger.Info("received shutdown signal", "signal", sig)
75+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
76+
r.Shutdown(ctx)
77+
cancel()
78+
logger.Info("http-pull stopped")
79+
return
80+
}
81+
}
82+
}

go.mod

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
module http-pull
2+
3+
go 1.24
4+
5+
require (
6+
github.com/spf13/pflag v1.0.10
7+
github.com/spf13/viper v1.21.0
8+
)
9+
10+
require (
11+
github.com/fsnotify/fsnotify v1.9.0 // indirect
12+
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
13+
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
14+
github.com/sagikazarmark/locafero v0.11.0 // indirect
15+
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
16+
github.com/spf13/afero v1.15.0 // indirect
17+
github.com/spf13/cast v1.10.0 // indirect
18+
github.com/subosito/gotenv v1.6.0 // indirect
19+
go.yaml.in/yaml/v3 v3.0.4 // indirect
20+
golang.org/x/sys v0.29.0 // indirect
21+
golang.org/x/text v0.28.0 // indirect
22+
)

go.sum

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
4+
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
5+
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
6+
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
7+
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
8+
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
9+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
10+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
11+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
12+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
13+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
14+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
15+
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
16+
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
17+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
18+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
19+
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
20+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
21+
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
22+
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
23+
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
24+
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
25+
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
26+
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
27+
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
28+
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
29+
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
30+
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
31+
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
32+
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
33+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
34+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
35+
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
36+
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
37+
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
38+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
39+
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
40+
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
41+
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
42+
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
43+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
44+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
45+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
46+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
47+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)