Skip to content

Commit 5000487

Browse files
committed
feat: implement timestamps metadata to indicate when secrets were last created/updates.
1 parent 35ce670 commit 5000487

6 files changed

Lines changed: 218 additions & 33 deletions

File tree

README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,22 @@
2222

2323
# envmap
2424

25-
`envmap` keeps secrets out of your Git history by sourcing them from a provider (local encrypted store, AWS SSM, Vault, etc.) and injecting them directly into the target process. No `.env` files, no accidental commits, no “who has the latest .env?” in Slack.
25+
`envmap` is a local env manager that keeps laptops, CI, and feature branches in sync with the remote secret stores you already trust. Instead of copying `.env` files or manually editing it, every repo declares a typed mapping of `env → provider path`, and you run `envmap run -- npm start` (or any command) to launch processes with fresh, provider-backed variables.
2626

27-
## Why?
27+
For devs not using a remote secrets manager - it doubles as an env-set switcher: define dev/staging/prod mappings once and jump between them without ever touching a plaintext `.env`. No more editing files or committing accidental changes or need to have a plaintext file with all your secrets exposed.
2828

29-
- `.env` files are easy to leak and hard to rotate across multiple engineers and machines.
30-
- Most teams already have a secrets backend (or should); local dev is the messy part.
31-
- `envmap` gives each repo a single, typed mapping from “env name → provider path” and a consistent `envmap run -- <cmd>` entrypoint.
29+
### Core benefits
30+
31+
- **Faster onboarding** – a new engineer can clone the repo, run `envmap init`, and immediately inherit the exact env vars if the remote secret stores are configured.
32+
- **Parity across environments** – map each env to its provider path once and guarantee that laptops, CI, and staging all get the right variables.
33+
- **Safer secret handling** – secrets live in providers, not Slack or git; rotations propagate automatically, and values never persist past the process lifetime.
34+
35+
### Why?
36+
37+
- Individual developers juggling multiple `.env` variants locally (dev vs staging vs production) without rewriting files or commenting out envs.
38+
- `.env` files are easy to leak, tough to rotate, and drift across environments.
39+
- Most teams already run a real secrets backend; envmap simply brings those secrets to local dev in a reproducible way.
40+
- A single `envmap run -- <cmd>` entrypoint ensures your tooling, CI, and developers share one contract for acquiring secrets.
3241

3342
## Installation
3443

env.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ func NewProvider(envName string, envCfg EnvConfig, globalCfg GlobalConfig) (prov
2727
}
2828

2929
func CollectEnv(ctx context.Context, projectCfg ProjectConfig, globalCfg GlobalConfig, envName string) (map[string]string, error) {
30+
records, err := CollectEnvWithMetadata(ctx, projectCfg, globalCfg, envName)
31+
if err != nil {
32+
return nil, err
33+
}
34+
out := make(map[string]string, len(records))
35+
for k, rec := range records {
36+
out[k] = rec.Value
37+
}
38+
return out, nil
39+
}
40+
41+
func CollectEnvWithMetadata(ctx context.Context, projectCfg ProjectConfig, globalCfg GlobalConfig, envName string) (map[string]provider.SecretRecord, error) {
3042
envCfg, ok := projectCfg.Envs[envName]
3143
if !ok {
3244
return nil, fmt.Errorf("env %q not found in project config", envName)
@@ -35,7 +47,7 @@ func CollectEnv(ctx context.Context, projectCfg ProjectConfig, globalCfg GlobalC
3547
if err != nil {
3648
return nil, err
3749
}
38-
return p.List(ctx, provider.ResolvedPrefix(envCfg.ToProviderConfig()))
50+
return provider.ListOrDescribe(ctx, p, provider.ResolvedPrefix(envCfg.ToProviderConfig()))
3951
}
4052

4153
func FetchSecret(ctx context.Context, projectCfg ProjectConfig, globalCfg GlobalConfig, envName, key string) (string, error) {

main.go

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"path/filepath"
1010
"sort"
1111
"strings"
12+
"time"
1213

1314
"github.com/binsquare/envmap/provider"
1415
"github.com/spf13/cobra"
@@ -132,26 +133,34 @@ func newEnvCmd() *cobra.Command {
132133
if err != nil {
133134
return err
134135
}
135-
secretEnv, err := CollectEnv(cmd.Context(), projectCfg, globalCfg, envToUse)
136+
records, err := CollectEnvWithMetadata(cmd.Context(), projectCfg, globalCfg, envToUse)
136137
if err != nil {
137138
return err
138139
}
139140

140141
// Sort keys for consistent output
141-
keys := make([]string, 0, len(secretEnv))
142-
for k := range secretEnv {
142+
keys := make([]string, 0, len(records))
143+
for k := range records {
143144
keys = append(keys, k)
144145
}
145146
sort.Strings(keys)
146147

147148
fmt.Fprintf(os.Stderr, "# env: %s (%d secrets)\n", envToUse, len(keys))
148149
for _, k := range keys {
149-
v := secretEnv[k]
150+
rec := records[k]
151+
v := rec.Value
150152
if raw {
151-
fmt.Printf("%s=%s\n", k, v)
153+
fmt.Printf("%s=%s", k, v)
152154
} else {
153-
fmt.Printf("%s=%s\n", k, MaskValue(v))
155+
fmt.Printf("%s=%s", k, MaskValue(v))
156+
}
157+
if !rec.CreatedAt.IsZero() {
158+
fmt.Printf(" # created %s", rec.CreatedAt.UTC().Format(time.RFC3339))
159+
if age := humanizeAge(rec.CreatedAt); age != "" {
160+
fmt.Printf(" (%s ago)", age)
161+
}
154162
}
163+
fmt.Println()
155164
}
156165
return nil
157166
},
@@ -244,6 +253,30 @@ func isSimpleValue(s string) bool {
244253
return len(s) > 0
245254
}
246255

256+
func humanizeAge(created time.Time) string {
257+
if created.IsZero() {
258+
return ""
259+
}
260+
d := time.Since(created)
261+
if d < 0 {
262+
d = 0
263+
}
264+
switch {
265+
case d < time.Minute:
266+
return fmt.Sprintf("%ds", int(d.Seconds()))
267+
case d < time.Hour:
268+
return fmt.Sprintf("%dm", int(d.Minutes()))
269+
case d < 24*time.Hour:
270+
return fmt.Sprintf("%dh", int(d.Hours()))
271+
case d < 30*24*time.Hour:
272+
return fmt.Sprintf("%dd", int(d.Hours()/24))
273+
case d < 365*24*time.Hour:
274+
return fmt.Sprintf("%dmo", int(d.Hours()/(24*30)))
275+
default:
276+
return fmt.Sprintf("%dyr", int(d.Hours()/(24*365)))
277+
}
278+
}
279+
247280
func newSetCmd() *cobra.Command {
248281
var envName string
249282
var fromFile string

provider/local.go

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"path/filepath"
1616
"strings"
1717
"sync"
18+
"time"
1819

1920
"github.com/gofrs/flock"
2021
"golang.org/x/crypto/hkdf"
@@ -47,6 +48,8 @@ type localFile struct {
4748
mu sync.Mutex
4849
}
4950

51+
var _ MetadataLister = (*localFile)(nil)
52+
5053
func newLocalFile(envCfg EnvConfig, providerCfg ProviderConfig) (Provider, error) {
5154
if providerCfg.Path == "" {
5255
return nil, fmt.Errorf("local-file provider missing path")
@@ -83,31 +86,21 @@ func (p *localFile) Get(_ context.Context, name string) (string, error) {
8386
if !ok {
8487
return fmt.Errorf("missing secret %s for env (expected from %s)", name, p.path)
8588
}
86-
value = val
89+
value = val.Value
8790
return nil
8891
})
8992
return value, err
9093
}
9194

92-
func (p *localFile) List(_ context.Context, prefix string) (map[string]string, error) {
93-
out := make(map[string]string)
94-
err := p.withExclusiveLock(func() error {
95-
entries, err := p.readAllUnlocked()
96-
if err != nil {
97-
return err
98-
}
99-
for name, val := range entries {
100-
if prefix != "" && !strings.HasPrefix(name, ensurePrefixSlash(prefix)) {
101-
continue
102-
}
103-
base := TrimPrefix(p.envCfg, name)
104-
out[base] = val
105-
}
106-
return nil
107-
})
95+
func (p *localFile) List(ctx context.Context, prefix string) (map[string]string, error) {
96+
records, err := p.ListWithMetadata(ctx, prefix)
10897
if err != nil {
10998
return nil, err
11099
}
100+
out := make(map[string]string, len(records))
101+
for k, rec := range records {
102+
out[k] = rec.Value
103+
}
111104
return out, nil
112105
}
113106

@@ -117,13 +110,18 @@ func (p *localFile) Set(_ context.Context, name, value string) error {
117110
if err != nil {
118111
return err
119112
}
120-
entries[name] = value
113+
entry := entries[name]
114+
if entry.CreatedAt.IsZero() {
115+
entry.CreatedAt = time.Now().UTC()
116+
}
117+
entry.Value = value
118+
entries[name] = entry
121119
return p.writeAllUnlocked(entries)
122120
})
123121
}
124122

125-
func (p *localFile) readAllUnlocked() (map[string]string, error) {
126-
entries := map[string]string{}
123+
func (p *localFile) readAllUnlocked() (map[string]SecretRecord, error) {
124+
entries := map[string]SecretRecord{}
127125
raw, err := os.ReadFile(p.path)
128126
if err != nil {
129127
if errors.Is(err, os.ErrNotExist) {
@@ -144,7 +142,7 @@ func (p *localFile) readAllUnlocked() (map[string]string, error) {
144142
return entries, nil
145143
}
146144

147-
func (p *localFile) writeAllUnlocked(entries map[string]string) error {
145+
func (p *localFile) writeAllUnlocked(entries map[string]SecretRecord) error {
148146
encoded, err := json.MarshalIndent(entries, "", " ")
149147
if err != nil {
150148
return fmt.Errorf("encode local store: %w", err)
@@ -199,6 +197,28 @@ func (p *localFile) writeAllUnlocked(entries map[string]string) error {
199197
return nil
200198
}
201199

200+
func (p *localFile) ListWithMetadata(_ context.Context, prefix string) (map[string]SecretRecord, error) {
201+
out := make(map[string]SecretRecord)
202+
err := p.withExclusiveLock(func() error {
203+
entries, err := p.readAllUnlocked()
204+
if err != nil {
205+
return err
206+
}
207+
for name, val := range entries {
208+
if prefix != "" && !strings.HasPrefix(name, ensurePrefixSlash(prefix)) {
209+
continue
210+
}
211+
base := TrimPrefix(p.envCfg, name)
212+
out[base] = val
213+
}
214+
return nil
215+
})
216+
if err != nil {
217+
return nil, err
218+
}
219+
return out, nil
220+
}
221+
202222
func (p *localFile) withExclusiveLock(fn func() error) error {
203223
if err := p.lock.Lock(); err != nil {
204224
return fmt.Errorf("acquire lock %s: %w", p.lock.Path(), err)

provider/local_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
)
10+
11+
func TestLocalFileStoresCreatedAt(t *testing.T) {
12+
t.Parallel()
13+
14+
dir := t.TempDir()
15+
keyPath := filepath.Join(dir, "key")
16+
if err := os.WriteFile(keyPath, bytesOfLen(32), 0o600); err != nil {
17+
t.Fatalf("write key: %v", err)
18+
}
19+
cfg := ProviderConfig{
20+
Path: filepath.Join(dir, "secrets.db"),
21+
Encryption: &EncryptionConfig{
22+
KeyFile: keyPath,
23+
},
24+
}
25+
envCfg := EnvConfig{PathPrefix: "/app/dev"}
26+
27+
p, err := newLocalFile(envCfg, cfg)
28+
if err != nil {
29+
t.Fatalf("newLocalFile: %v", err)
30+
}
31+
lf := p.(*localFile)
32+
33+
ctx := context.Background()
34+
fullKey := ApplyPrefix(envCfg, "DB_URL")
35+
if err := lf.Set(ctx, fullKey, "postgres://example"); err != nil {
36+
t.Fatalf("Set: %v", err)
37+
}
38+
39+
records, err := lf.ListWithMetadata(ctx, ResolvedPrefix(envCfg))
40+
if err != nil {
41+
t.Fatalf("ListWithMetadata: %v", err)
42+
}
43+
rec, ok := records["DB_URL"]
44+
if !ok {
45+
t.Fatalf("expected DB_URL to be returned, got keys %v", records)
46+
}
47+
if rec.Value != "postgres://example" {
48+
t.Fatalf("value mismatch: got %q", rec.Value)
49+
}
50+
if rec.CreatedAt.IsZero() {
51+
t.Fatalf("expected CreatedAt to be set")
52+
}
53+
54+
// Reopen provider to ensure CreatedAt persisted to disk.
55+
time.Sleep(10 * time.Millisecond) // allow measurable difference if overwritten
56+
p2, err := newLocalFile(envCfg, cfg)
57+
if err != nil {
58+
t.Fatalf("re-open newLocalFile: %v", err)
59+
}
60+
records2, err := p2.(*localFile).ListWithMetadata(ctx, ResolvedPrefix(envCfg))
61+
if err != nil {
62+
t.Fatalf("ListWithMetadata second read: %v", err)
63+
}
64+
rec2 := records2["DB_URL"]
65+
if !rec.CreatedAt.Equal(rec2.CreatedAt) {
66+
t.Fatalf("created timestamps differ: first=%v second=%v", rec.CreatedAt, rec2.CreatedAt)
67+
}
68+
}
69+
70+
func bytesOfLen(n int) []byte {
71+
buf := make([]byte, n)
72+
for i := range buf {
73+
buf[i] = byte(1 + (i % 250))
74+
}
75+
return buf
76+
}

provider/metadata.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// SecretRecord carries a secret's value plus optional metadata for presentation.
9+
type SecretRecord struct {
10+
Value string `json:"value"`
11+
CreatedAt time.Time `json:"created_at,omitempty"`
12+
}
13+
14+
// MetadataLister can return values plus metadata in one call.
15+
type MetadataLister interface {
16+
ListWithMetadata(ctx context.Context, prefix string) (map[string]SecretRecord, error)
17+
}
18+
19+
// ListOrDescribe fetches secrets with metadata when the provider supports it.
20+
// For providers that do not expose metadata, the map still contains values,
21+
// but CreatedAt is left zero to signal "unknown".
22+
func ListOrDescribe(ctx context.Context, p Provider, prefix string) (map[string]SecretRecord, error) {
23+
if lister, ok := p.(MetadataLister); ok {
24+
return lister.ListWithMetadata(ctx, prefix)
25+
}
26+
values, err := p.List(ctx, prefix)
27+
if err != nil {
28+
return nil, err
29+
}
30+
records := make(map[string]SecretRecord, len(values))
31+
for k, v := range values {
32+
records[k] = SecretRecord{Value: v}
33+
}
34+
return records, nil
35+
}

0 commit comments

Comments
 (0)