Skip to content

Commit ca86640

Browse files
authored
Storage: local JSON file (#123)
1 parent 6f010ba commit ca86640

File tree

5 files changed

+400
-2
lines changed

5 files changed

+400
-2
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ fuzz:
3737

3838
.PHONY: golangcicheck
3939
golangcicheck:
40-
@/bin/bash -c "type -P golangci-lint;" 2>/dev/null || (echo "golangci-lint is required but not available in current PATH. Install: https://github.com/golangci/golangci-lint#install"; exit 1)
40+
@bash -c "type -P golangci-lint;" 2>/dev/null || (echo "golangci-lint is required but not available in current PATH. Install: https://github.com/golangci/golangci-lint#install"; exit 1)
4141

4242
.PHONY: lint
4343
lint: golangcicheck

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ docker run -it --rm --entrypoint /cli ghcr.io/metal-stack/go-ipam
138138
| Database | Acquire Child Prefix | Acquire IP | New Prefix | Prefix Overlap | Production-Ready | Geo-Redundant |
139139
|:------------|---------------------:|------------:|------------:|---------------:|:-----------------|:--------------|
140140
| In-Memory | 106,861/sec | 196,687/sec | 330,578/sec | 248/sec | N | N |
141+
| File | | | | | N | N |
141142
| KeyDB | 777/sec | 975/sec | 2,271/sec | | Y | Y |
142143
| Redis | 773/sec | 958/sec | 2,349/sec | | Y | N |
143144
| MongoDB | 415/sec | 682/sec | 772/sec | | Y | Y |

cmd/server/main.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,26 @@ func main() {
5151
return s.Run()
5252
},
5353
},
54+
{
55+
Name: "file",
56+
Aliases: []string{"f", "local"},
57+
Usage: "start with local JSON file backend",
58+
Flags: []cli.Flag{
59+
&cli.StringFlag{
60+
Name: "path",
61+
Value: goipam.DefaultLocalFilePath,
62+
DefaultText: "~/.local/share/go-ipam/ipam-db.json",
63+
Usage: "path to the file",
64+
EnvVars: []string{"GOIPAM_FILE_PATH"},
65+
},
66+
},
67+
Action: func(ctx *cli.Context) error {
68+
c := getConfig(ctx)
69+
c.Storage = goipam.NewLocalFile(ctx.Context, ctx.String("path"))
70+
s := newServer(c)
71+
return s.Run()
72+
},
73+
},
5474
{
5575
Name: "postgres",
5676
Aliases: []string{"pg"},

file.go

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
package ipam
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io/fs"
9+
"os"
10+
"path"
11+
"sync"
12+
"time"
13+
)
14+
15+
type file struct {
16+
// path location of the state file
17+
path string
18+
// prettyJSON is always true, but up for being configurable
19+
prettyJSON bool
20+
// modTime helps with tracking external file changes
21+
// usages of modTime will be deprecated after implementing filesystem-level locking
22+
modTime time.Time
23+
// parent implements internal state management, currently it is always NewMemory()
24+
parent Storage
25+
// lock at some point should be replaced with filesystem lock
26+
lock sync.RWMutex
27+
}
28+
29+
var (
30+
nullModTime time.Time
31+
DefaultLocalFilePath string
32+
)
33+
34+
// fileJSONData is a representation of JSON file's structure
35+
type fileJSONData map[string]map[string]prefixJSON
36+
37+
func init() {
38+
nullModTime = time.Unix(0, 0)
39+
DefaultLocalFilePath = path.Join(getXDGDataHome(), "go-ipam", "ipam-db.json")
40+
}
41+
42+
// getXDGDataHome() is an utility for finding the most suitable data directory:
43+
// 1) $XDG_DATA_HOME
44+
// 2) $HOME/.local/share
45+
// 3) current directory
46+
func getXDGDataHome() string {
47+
if val := os.Getenv("XDG_DATA_HOME"); val != "" {
48+
return val
49+
}
50+
51+
val, err := os.UserHomeDir()
52+
if err != nil {
53+
val = "."
54+
} else {
55+
val = path.Join(val, ".local", "share")
56+
}
57+
return val
58+
}
59+
60+
// NewLocalFile creates a JSON file storage for ipam
61+
func NewLocalFile(ctx context.Context, path string) Storage {
62+
return &file{
63+
path: path,
64+
prettyJSON: true,
65+
parent: NewMemory(ctx),
66+
modTime: nullModTime,
67+
lock: sync.RWMutex{},
68+
}
69+
}
70+
71+
// clearParent() empties the internal state
72+
func (f *file) clearParent(ctx context.Context) (err error) {
73+
namespaces, err := f.parent.ListNamespaces(ctx)
74+
if err != nil {
75+
return fmt.Errorf("failed to list namespaces: %w", err)
76+
}
77+
for _, namespace := range namespaces {
78+
if err = f.parent.DeleteAllPrefixes(ctx, namespace); err != nil {
79+
return fmt.Errorf("failed to delete prefixes for %s namespace: %w", namespace, err)
80+
}
81+
if namespace == defaultNamespace {
82+
// skip deletion instead of replicating NewMemory behavior
83+
continue
84+
}
85+
if err = f.parent.DeleteNamespace(ctx, namespace); err != nil {
86+
return fmt.Errorf("failed to delete %s namespace: %w", namespace, err)
87+
}
88+
}
89+
return nil
90+
}
91+
92+
// getModTime() returns file's modification time without failure (defaulting to nullModTime).
93+
func (f *file) getModTime() time.Time {
94+
info, err := os.Stat(f.path)
95+
if err != nil {
96+
return nullModTime
97+
}
98+
return info.ModTime()
99+
}
100+
101+
// reload() is meant to synchronize from the file to internal state representation,
102+
// currently it bases decision solely on the modification time of the file
103+
//
104+
// see ipamer.NamespacedLoad for alternative implementation candidate
105+
// after https://github.com/metal-stack/go-ipam/issues/111 is addressed
106+
func (f *file) reload(ctx context.Context) (err error) {
107+
// don't do anything when file modification time didn't changed
108+
if modTime := f.getModTime(); modTime != nullModTime && modTime == f.modTime {
109+
return nil
110+
}
111+
112+
var data []byte
113+
storage := make(fileJSONData)
114+
if _, err = os.Stat(f.path); !errors.Is(err, fs.ErrNotExist) {
115+
data, err = os.ReadFile(f.path)
116+
if err != nil {
117+
return fmt.Errorf("failed to read state file %q: %w", f.path, err)
118+
}
119+
}
120+
f.modTime = f.getModTime()
121+
// smallest valid piece of data is "{}"
122+
if len(data) >= 2 {
123+
err = json.Unmarshal(data, &storage)
124+
if err != nil {
125+
return fmt.Errorf("failed to parse state file %q: %w", f.path, err)
126+
}
127+
}
128+
if err = f.clearParent(ctx); err != nil {
129+
return fmt.Errorf("failed to clear memory storage: %w", err)
130+
}
131+
for namespace, prefixes := range storage {
132+
if err = f.parent.CreateNamespace(ctx, namespace); err != nil {
133+
return fmt.Errorf("failed to reload a %s namespace: %w", namespace, err)
134+
}
135+
for _, prefix := range prefixes {
136+
if _, err = f.parent.CreatePrefix(ctx, prefix.toPrefix(), namespace); err != nil {
137+
return fmt.Errorf("failed to reload a %s prefix in %s namespace: %w", prefix.Cidr, namespace, err)
138+
}
139+
}
140+
}
141+
return nil
142+
}
143+
144+
// persist() dumps current internal state to file
145+
//
146+
// see ipamer.NamespacedDump for alternative implementation candidate
147+
// after https://github.com/metal-stack/go-ipam/issues/111 is addressed
148+
func (f *file) persist(ctx context.Context) (err error) {
149+
storage := make(fileJSONData)
150+
var (
151+
prefixes map[string]prefixJSON
152+
ok bool
153+
data []byte
154+
)
155+
156+
namespaces, err := f.parent.ListNamespaces(ctx)
157+
if err != nil {
158+
return fmt.Errorf("failed to list namespaces while building external state representation: %w", err)
159+
}
160+
for _, namespace := range namespaces {
161+
if prefixes, ok = storage[namespace]; !ok {
162+
prefixes = make(map[string]prefixJSON)
163+
storage[namespace] = prefixes
164+
}
165+
ps, err := f.parent.ReadAllPrefixes(ctx, namespace)
166+
if err != nil {
167+
return fmt.Errorf("failed to read prefixes of %s namespace while building external state representation: %w", namespace, err)
168+
}
169+
for _, prefix := range ps {
170+
prefixes[prefix.Cidr] = prefix.toPrefixJSON()
171+
}
172+
}
173+
if f.prettyJSON {
174+
data, err = json.MarshalIndent(storage, "", " ")
175+
} else {
176+
data, err = json.Marshal(storage)
177+
}
178+
if err != nil {
179+
return fmt.Errorf("failed to serialize JSON: %w", err)
180+
}
181+
err = os.WriteFile(f.path, data, 0600)
182+
if err != nil {
183+
return fmt.Errorf("error storing state at %q: %w", f.path, err)
184+
}
185+
f.modTime = f.getModTime()
186+
return err
187+
}
188+
func (f *file) Name() string {
189+
return "file"
190+
}
191+
192+
func (f *file) CreatePrefix(ctx context.Context, prefix Prefix, namespace string) (p Prefix, err error) {
193+
f.lock.Lock()
194+
defer f.lock.Unlock()
195+
196+
if err = f.reload(ctx); err != nil {
197+
return p, err
198+
}
199+
200+
if p, err = f.parent.CreatePrefix(ctx, prefix, namespace); err != nil {
201+
return p, err
202+
}
203+
204+
return p, f.persist(ctx)
205+
}
206+
207+
func (f *file) ReadPrefix(ctx context.Context, prefix, namespace string) (p Prefix, err error) {
208+
f.lock.RLock()
209+
defer f.lock.RUnlock()
210+
if err = f.reload(ctx); err != nil {
211+
return p, err
212+
}
213+
return f.parent.ReadPrefix(ctx, prefix, namespace)
214+
}
215+
216+
func (f *file) DeleteAllPrefixes(ctx context.Context, namespace string) (err error) {
217+
f.lock.RLock()
218+
defer f.lock.RUnlock()
219+
220+
if err = f.reload(ctx); err != nil {
221+
return err
222+
}
223+
if err = f.parent.DeleteAllPrefixes(ctx, namespace); err != nil {
224+
return err
225+
}
226+
return f.persist(ctx)
227+
}
228+
229+
func (f *file) ReadAllPrefixes(ctx context.Context, namespace string) (ps Prefixes, err error) {
230+
f.lock.RLock()
231+
defer f.lock.RUnlock()
232+
233+
if err = f.reload(ctx); err != nil {
234+
return ps, err
235+
}
236+
return f.parent.ReadAllPrefixes(ctx, namespace)
237+
}
238+
239+
func (f *file) ReadAllPrefixCidrs(ctx context.Context, namespace string) (cidrs []string, err error) {
240+
f.lock.RLock()
241+
defer f.lock.RUnlock()
242+
243+
if err = f.reload(ctx); err != nil {
244+
return cidrs, err
245+
}
246+
return f.parent.ReadAllPrefixCidrs(ctx, namespace)
247+
}
248+
249+
func (f *file) UpdatePrefix(ctx context.Context, prefix Prefix, namespace string) (p Prefix, err error) {
250+
f.lock.Lock()
251+
defer f.lock.Unlock()
252+
253+
if err = f.reload(ctx); err != nil {
254+
return p, err
255+
}
256+
if p, err = f.parent.UpdatePrefix(ctx, prefix, namespace); err != nil {
257+
return p, err
258+
}
259+
return p, f.persist(ctx)
260+
}
261+
func (f *file) DeletePrefix(ctx context.Context, prefix Prefix, namespace string) (p Prefix, err error) {
262+
f.lock.Lock()
263+
defer f.lock.Unlock()
264+
265+
if err = f.reload(ctx); err != nil {
266+
return p, err
267+
}
268+
if p, err = f.parent.DeletePrefix(ctx, prefix, namespace); err != nil {
269+
return p, err
270+
}
271+
return p, f.persist(ctx)
272+
}
273+
274+
func (f *file) CreateNamespace(ctx context.Context, namespace string) (err error) {
275+
f.lock.Lock()
276+
defer f.lock.Unlock()
277+
278+
if err = f.reload(ctx); err != nil {
279+
return err
280+
}
281+
if err = f.parent.CreateNamespace(ctx, namespace); err != nil {
282+
return err
283+
}
284+
return f.persist(ctx)
285+
}
286+
287+
func (f *file) ListNamespaces(ctx context.Context) (result []string, err error) {
288+
f.lock.Lock()
289+
defer f.lock.Unlock()
290+
if err = f.reload(ctx); err != nil {
291+
return result, err
292+
}
293+
return f.parent.ListNamespaces(ctx)
294+
}
295+
296+
func (f *file) DeleteNamespace(ctx context.Context, namespace string) (err error) {
297+
f.lock.Lock()
298+
defer f.lock.Unlock()
299+
300+
if err = f.reload(ctx); err != nil {
301+
return err
302+
}
303+
if err = f.parent.DeleteNamespace(ctx, namespace); err != nil {
304+
return err
305+
}
306+
return f.persist(ctx)
307+
}

0 commit comments

Comments
 (0)