Skip to content

Commit a96152d

Browse files
committed
feat: optionally set permissions and ownership for pulled files
1 parent 7ff3f2b commit a96152d

7 files changed

Lines changed: 151 additions & 2 deletions

File tree

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ Simple service that pulls files via http(s) in configurable intervals, stores th
44
## Features
55
- **Easily Configurable** - Configure which files to pull in which interval and what to do with them in a single configuration file
66
- **Sub-Minute Intervals** - Pull files as often (or as infrequent) as you need to
7-
- **Atomic Writes** - Files are downloaded into a temporary file, first, then atomically moved into place (`inotify`-compatible)
7+
- **Atomic Writes** - Files are downloaded into a temporary file first, then atomically moved into place (`inotify`-compatible)
8+
- **File Permissions** - Optionally set file mode, owner, and group — applied before the atomic rename, so watchers never see incorrect permissions
89
- **Hooks** - Configure hooks to be executed after a pull was completed successfully or after it failed
910
- **Extensible** - Easily develop own hooks by satisfying a simple Go interface with just one method
1011

@@ -51,6 +52,10 @@ targets:
5152
username: username
5253
password: password
5354
follow_redirects: true # default: true
55+
file: # optional
56+
mode: "0644" # octal file mode
57+
owner: www-data # user name or numeric UID
58+
group: www-data # group name or numeric GID
5459
hooks: # optional
5560
- type: shell # `shell` or `move`
5661
command: echo "Downloaded successfully"
@@ -61,6 +66,20 @@ targets:
6166
destination: /opt/data/data.txt
6267
```
6368
69+
## File Permissions
70+
Each target can include an optional `file` block to set mode, owner, and group on the downloaded file. Permissions are applied to the temporary file **before** the atomic rename, so services watching the destination (e.g. via `inotify`) will never observe a file with incorrect permissions.
71+
72+
All three fields are optional and can be used independently:
73+
74+
```yaml
75+
file:
76+
mode: "0755" # octal string
77+
owner: deploy # user name or numeric UID
78+
group: staff # group name or numeric GID
79+
```
80+
81+
Setting `owner` or `group` typically requires running `http-pull` as root.
82+
6483
## Hooks
6584

6685
**`shell`** — Executes a command via `/bin/sh -c` after the pull completes.

cmd/http-pull/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,19 @@ import (
1515
"http-pull/internal/runner"
1616
)
1717

18+
// version is set at build time via ldflags.
19+
var version = "dev"
20+
1821
func main() {
1922
configPath := pflag.String("config", "/etc/http-pull/config.yaml", "path to configuration file")
23+
showVersion := pflag.Bool("version", false, "print version and exit")
2024
pflag.Parse()
2125

26+
if *showVersion {
27+
fmt.Println("http-pull", version)
28+
return
29+
}
30+
2231
cfg, err := config.Load(*configPath)
2332
if err != nil {
2433
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)

internal/config/config.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33

44
import (
55
"fmt"
6+
"strconv"
67
"strings"
78
"time"
89

@@ -29,9 +30,17 @@ type TargetConfig struct {
2930
Interval time.Duration `mapstructure:"interval"`
3031
Destination string `mapstructure:"destination"`
3132
HTTPRequest *HTTPRequestConfig `mapstructure:"http_request"`
33+
File *FileConfig `mapstructure:"file"`
3234
Hooks []HookConfig `mapstructure:"hooks"`
3335
}
3436

37+
// FileConfig holds optional file ownership and permission settings.
38+
type FileConfig struct {
39+
Owner string `mapstructure:"owner"`
40+
Group string `mapstructure:"group"`
41+
Mode string `mapstructure:"mode"`
42+
}
43+
3544
// HTTPRequestConfig holds optional HTTP request settings.
3645
type HTTPRequestConfig struct {
3746
Method string `mapstructure:"method"`
@@ -154,6 +163,12 @@ func validate(cfg *Config) error {
154163
return fmt.Errorf("target %q: destination is required", t.Name)
155164
}
156165

166+
if t.File != nil && t.File.Mode != "" {
167+
if _, err := strconv.ParseUint(t.File.Mode, 8, 32); err != nil {
168+
return fmt.Errorf("target %q: invalid file mode %q: must be an octal string (e.g. \"0644\")", t.Name, t.File.Mode)
169+
}
170+
}
171+
157172
for j, h := range t.Hooks {
158173
if err := validateHook(t.Name, j, h); err != nil {
159174
return err

internal/hook/move.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,10 @@ func copyFile(src, dst string) error {
6969
return err
7070
}
7171

72-
return out.Close()
72+
if err := out.Close(); err != nil {
73+
return err
74+
}
75+
76+
// Preserve file ownership across the copy.
77+
return preserveOwnership(info, dst)
7378
}

internal/hook/ownership_other.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build !unix
2+
3+
package hook
4+
5+
import "os"
6+
7+
// preserveOwnership is a no-op on non-Unix platforms.
8+
func preserveOwnership(_ os.FileInfo, _ string) error {
9+
return nil
10+
}

internal/hook/ownership_unix.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//go:build unix
2+
3+
package hook
4+
5+
import (
6+
"os"
7+
"syscall"
8+
)
9+
10+
func preserveOwnership(info os.FileInfo, dst string) error {
11+
stat, ok := info.Sys().(*syscall.Stat_t)
12+
if !ok {
13+
return nil
14+
}
15+
return os.Chown(dst, int(stat.Uid), int(stat.Gid))
16+
}

internal/puller/puller.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"log/slog"
99
"net/http"
1010
"os"
11+
"os/user"
1112
"path/filepath"
13+
"strconv"
1214

1315
"http-pull/internal/config"
1416
)
@@ -110,10 +112,83 @@ func (p *Puller) atomicWrite(r io.Reader) error {
110112
return fmt.Errorf("closing temp file: %w", err)
111113
}
112114

115+
if err := p.applyFilePermissions(tmpPath); err != nil {
116+
os.Remove(tmpPath)
117+
return fmt.Errorf("applying file permissions: %w", err)
118+
}
119+
113120
if err := os.Rename(tmpPath, p.target.Destination); err != nil {
114121
os.Remove(tmpPath)
115122
return fmt.Errorf("renaming temp file: %w", err)
116123
}
117124

118125
return nil
119126
}
127+
128+
func (p *Puller) applyFilePermissions(path string) error {
129+
fc := p.target.File
130+
if fc == nil {
131+
return nil
132+
}
133+
134+
if fc.Mode != "" {
135+
parsed, err := strconv.ParseUint(fc.Mode, 8, 32)
136+
if err != nil {
137+
return fmt.Errorf("parsing mode %q: %w", fc.Mode, err)
138+
}
139+
if err := os.Chmod(path, os.FileMode(parsed)); err != nil {
140+
return fmt.Errorf("chmod: %w", err)
141+
}
142+
p.logger.Debug("applied file mode", "path", path, "mode", fc.Mode)
143+
}
144+
145+
if fc.Owner != "" || fc.Group != "" {
146+
uid := -1
147+
gid := -1
148+
149+
if fc.Owner != "" {
150+
id, err := resolveUID(fc.Owner)
151+
if err != nil {
152+
return fmt.Errorf("resolving owner %q: %w", fc.Owner, err)
153+
}
154+
uid = id
155+
}
156+
157+
if fc.Group != "" {
158+
id, err := resolveGID(fc.Group)
159+
if err != nil {
160+
return fmt.Errorf("resolving group %q: %w", fc.Group, err)
161+
}
162+
gid = id
163+
}
164+
165+
if err := os.Chown(path, uid, gid); err != nil {
166+
return fmt.Errorf("chown: %w", err)
167+
}
168+
p.logger.Debug("applied file ownership", "path", path, "uid", uid, "gid", gid)
169+
}
170+
171+
return nil
172+
}
173+
174+
func resolveUID(name string) (int, error) {
175+
if id, err := strconv.Atoi(name); err == nil {
176+
return id, nil
177+
}
178+
u, err := user.Lookup(name)
179+
if err != nil {
180+
return 0, err
181+
}
182+
return strconv.Atoi(u.Uid)
183+
}
184+
185+
func resolveGID(name string) (int, error) {
186+
if id, err := strconv.Atoi(name); err == nil {
187+
return id, nil
188+
}
189+
g, err := user.LookupGroup(name)
190+
if err != nil {
191+
return 0, err
192+
}
193+
return strconv.Atoi(g.Gid)
194+
}

0 commit comments

Comments
 (0)