Skip to content

Commit d7e0ad8

Browse files
dilyevskyclaude
andcommitted
[cli] add DomainRecord command under alpha domains
Add `apoxy alpha domains` command for managing DomainRecord objects with get/list/create/delete/apply. Uses name/TYPE format (e.g. example.com/A) for get and delete, which maps to the internal metadata name. Table output shows spec.name instead of metadata.name for readability. Also fix double error printing by setting SilenceErrors on root command and switching main.go from log.Fatalf to fmt.Fprintf for error output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b4283fc commit d7e0ad8

File tree

8 files changed

+370
-7
lines changed

8 files changed

+370
-7
lines changed

.claude/plan-eszip-store.md

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
# Eszip Store Implementation Plan
2+
3+
## Overview
4+
5+
Implement an abstracted storage layer for eszip bundles (and other EdgeFunction assets like .wasm and .so files) with two backends:
6+
- **File-backed store** - For local development and testing
7+
- **S3-backed store** - For production deployments
8+
9+
## Current State
10+
11+
EdgeFunction assets are stored locally at `$baseDir/run/ingest/store/{name}/` with explicit TODOs at `pkg/apiserver/ingest/edgefunction.go:405,634,764` indicating intent to migrate to object store. The current HTTP server (`ListenAndServeEdgeFuncs`) reads directly from the filesystem.
12+
13+
## Proposed Architecture
14+
15+
```
16+
pkg/apiserver/ingest/store/
17+
├── store.go # Interface + factory
18+
├── file.go # File-backed implementation
19+
├── s3.go # S3-backed implementation
20+
└── store_test.go # Tests
21+
```
22+
23+
## Interface Design
24+
25+
```go
26+
// pkg/apiserver/ingest/store/store.go
27+
28+
package store
29+
30+
import (
31+
"context"
32+
"io"
33+
)
34+
35+
// AssetType identifies the type of EdgeFunction asset
36+
type AssetType string
37+
38+
const (
39+
AssetTypeEszip AssetType = "eszip" // JavaScript bundle
40+
AssetTypeWasm AssetType = "wasm" // WebAssembly module
41+
AssetTypeGo AssetType = "go" // Go plugin (.so)
42+
)
43+
44+
// Store provides storage operations for EdgeFunction assets
45+
type Store interface {
46+
// Put stores an asset. The reader is consumed and closed by the implementation.
47+
Put(ctx context.Context, ref string, assetType AssetType, r io.Reader) error
48+
49+
// Get retrieves an asset. Caller must close the returned ReadCloser.
50+
// Returns os.ErrNotExist if not found.
51+
Get(ctx context.Context, ref string, assetType AssetType) (io.ReadCloser, error)
52+
53+
// Delete removes an asset.
54+
Delete(ctx context.Context, ref string, assetType AssetType) error
55+
56+
// Exists checks if an asset exists.
57+
Exists(ctx context.Context, ref string, assetType AssetType) (bool, error)
58+
}
59+
```
60+
61+
## File Store Implementation
62+
63+
```go
64+
// pkg/apiserver/ingest/store/file.go
65+
66+
type FileStore struct {
67+
baseDir string
68+
}
69+
70+
func NewFileStore(baseDir string) (*FileStore, error) {
71+
// Creates baseDir if it doesn't exist
72+
}
73+
74+
// Key layout: {baseDir}/{ref}/{assetType}
75+
// e.g., /data/store/my-func-rev-abc123/eszip
76+
```
77+
78+
**Key behaviors:**
79+
- Atomic writes using temp file + rename (existing symlink pattern)
80+
- Direct file reads with `os.Open`
81+
- Compatible with existing HTTP serving (can mount same directory)
82+
83+
## S3 Store Implementation
84+
85+
```go
86+
// pkg/apiserver/ingest/store/s3.go
87+
88+
type S3Store struct {
89+
client *s3.Client
90+
bucket string
91+
prefix string // optional key prefix
92+
}
93+
94+
type S3Config struct {
95+
Region string
96+
Bucket string
97+
Prefix string
98+
Endpoint string // for MinIO/localstack compatibility
99+
}
100+
101+
func NewS3Store(ctx context.Context, cfg S3Config) (*S3Store, error) {
102+
// Uses AWS SDK v2 with default credential chain
103+
}
104+
105+
// Key layout: {prefix}/{ref}/{assetType}
106+
// e.g., s3://my-bucket/edgefuncs/my-func-rev-abc123/eszip
107+
```
108+
109+
**Key behaviors:**
110+
- Uses `s3.PutObject` with streaming upload
111+
- Uses `s3.GetObject` returning the response body as ReadCloser
112+
- Supports custom endpoints for MinIO/LocalStack testing
113+
114+
## Configuration
115+
116+
Add to existing config or environment:
117+
118+
```go
119+
// pkg/apiserver/ingest/config.go or similar
120+
121+
type StoreConfig struct {
122+
// Type selects the store backend: "file" or "s3"
123+
Type string `json:"type" yaml:"type"`
124+
125+
// File store options (when Type = "file")
126+
File struct {
127+
BaseDir string `json:"baseDir" yaml:"baseDir"`
128+
} `json:"file" yaml:"file"`
129+
130+
// S3 store options (when Type = "s3")
131+
S3 struct {
132+
Region string `json:"region" yaml:"region"`
133+
Bucket string `json:"bucket" yaml:"bucket"`
134+
Prefix string `json:"prefix" yaml:"prefix"`
135+
Endpoint string `json:"endpoint" yaml:"endpoint"` // optional
136+
} `json:"s3" yaml:"s3"`
137+
}
138+
```
139+
140+
## Integration Points
141+
142+
### 1. Replace direct filesystem calls in `edgefunction.go`
143+
144+
Current (line ~764):
145+
```go
146+
err = os.Rename(stagingPath, filepath.Join(storeDir, "bin.eszip"))
147+
```
148+
149+
New:
150+
```go
151+
f, err := os.Open(stagingPath)
152+
if err != nil { return err }
153+
defer f.Close()
154+
err = w.store.Put(ctx, name, store.AssetTypeEszip, f)
155+
```
156+
157+
### 2. Update HTTP handler (`ServeHTTP`)
158+
159+
Current:
160+
```go
161+
p := filepath.Join(storeDir(name), filename)
162+
http.ServeFile(wr, req, p)
163+
```
164+
165+
New:
166+
```go
167+
rc, err := w.store.Get(req.Context(), name, assetType)
168+
if err != nil {
169+
if os.IsNotExist(err) {
170+
http.NotFound(wr, req)
171+
return
172+
}
173+
http.Error(wr, err.Error(), http.StatusInternalServerError)
174+
return
175+
}
176+
defer rc.Close()
177+
io.Copy(wr, rc)
178+
```
179+
180+
### 3. Cleanup in workflows
181+
182+
Current:
183+
```go
184+
os.RemoveAll(storeDir(name))
185+
```
186+
187+
New:
188+
```go
189+
w.store.Delete(ctx, name, store.AssetTypeEszip)
190+
// etc for other asset types
191+
```
192+
193+
## Implementation Steps
194+
195+
1. **Create store package with interface** (`pkg/apiserver/ingest/store/store.go`)
196+
197+
2. **Implement FileStore** (`file.go`)
198+
- Constructor with directory creation
199+
- Put with atomic write (temp + rename)
200+
- Get returning os.File
201+
- Delete and Exists
202+
203+
3. **Implement S3Store** (`s3.go`)
204+
- Use AWS SDK v2 (`github.com/aws/aws-sdk-go-v2`)
205+
- Constructor with config loading
206+
- Put with streaming PutObject
207+
- Get returning GetObject response body
208+
- Delete and Exists (HeadObject)
209+
210+
4. **Add factory function** (`store.go`)
211+
```go
212+
func New(cfg StoreConfig) (Store, error)
213+
```
214+
215+
5. **Write tests** (`store_test.go`)
216+
- Unit tests with FileStore
217+
- Integration test pattern for S3 (LocalStack or skip)
218+
219+
6. **Integrate into worker** (`edgefunction.go`)
220+
- Add store field to worker struct
221+
- Update StoreEszipActivity
222+
- Update StoreWasmActivity
223+
- Update StoreGoActivity
224+
- Update ServeHTTP handler
225+
226+
7. **Wire up configuration**
227+
- Add StoreConfig to worker options
228+
- Default to FileStore for backwards compatibility
229+
230+
## Testing Strategy
231+
232+
- **FileStore**: Standard unit tests with temp directories
233+
- **S3Store**:
234+
- Unit tests with mock S3 client interface
235+
- Optional integration tests with LocalStack (via `endpoint` config)
236+
- **Integration**: Existing EdgeFunction workflow tests should continue to pass
237+
238+
## Dependencies to Add
239+
240+
```
241+
github.com/aws/aws-sdk-go-v2
242+
github.com/aws/aws-sdk-go-v2/config
243+
github.com/aws/aws-sdk-go-v2/service/s3
244+
```
245+
246+
## Backwards Compatibility
247+
248+
- Default store type = "file" with existing baseDir location
249+
- Existing deployments continue to work without config changes
250+
- HTTP serving interface unchanged (backplane compatibility)
251+
252+
## Future Considerations (Out of Scope)
253+
254+
- Signed URL generation for direct S3 downloads (bypass apiserver)
255+
- Cache layer for frequently accessed assets
256+
- Multi-region replication
257+
- Compression/deduplication

.claude/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"permissions": {
3+
"allow": ["Bash(apoxy --local:*)"]
4+
}
5+
}

api/core/v1alpha3/domainrecord_types.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ func domainRecordToTable(r *DomainRecord, tableOptions runtime.Object) (*metav1.
430430

431431
table.Rows = append(table.Rows, metav1.TableRow{
432432
Cells: []interface{}{
433-
r.Name,
433+
r.Spec.Name,
434434
domainRecordTypeString(r),
435435
r.Spec.Zone,
436436
domainRecordTTL(r),
@@ -456,7 +456,7 @@ func domainRecordListToTable(list *DomainRecordList, tableOptions runtime.Object
456456
r := &list.Items[i]
457457
table.Rows = append(table.Rows, metav1.TableRow{
458458
Cells: []interface{}{
459-
r.Name,
459+
r.Spec.Name,
460460
domainRecordTypeString(r),
461461
r.Spec.Zone,
462462
domainRecordTTL(r),

main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package main
1717

1818
import (
1919
"context"
20+
"fmt"
2021
"log"
2122
"os"
2223
"os/signal"
@@ -50,7 +51,7 @@ func main() {
5051
}()
5152

5253
if err := cmd.ExecuteContext(ctx); err != nil {
53-
log.Fatalf("Error: %s", err)
54+
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
5455
os.Exit(1)
5556
}
5657
}

pkg/cmd/alpha/alpha.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ func Cmd() *cobra.Command {
1919

2020
func init() {
2121
alphaCmd.AddCommand(tunnelCmd)
22+
alphaCmd.AddCommand(domainRecordResource.Build())
2223
}

pkg/cmd/alpha/domainrecord.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package alpha
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
9+
corev1alpha3 "github.com/apoxy-dev/apoxy/api/core/v1alpha3"
10+
"github.com/apoxy-dev/apoxy/pkg/cmd/resource"
11+
"github.com/apoxy-dev/apoxy/rest"
12+
)
13+
14+
// dnsTypeToFieldKey maps the user-visible DNS record type to the internal
15+
// field key used in the metadata.name (e.g. "example.com--ips").
16+
var dnsTypeToFieldKey = map[string]string{
17+
"a": "ips",
18+
"aaaa": "ips",
19+
"cname": "fqdn",
20+
"txt": "txt",
21+
"mx": "mx",
22+
"dkim": "dkim",
23+
"spf": "spf",
24+
"dmarc": "dmarc",
25+
"caa": "caa",
26+
"srv": "srv",
27+
"ns": "ns",
28+
"ds": "ds",
29+
"dnskey": "dnskey",
30+
"ref": "ref",
31+
}
32+
33+
// domainRecordNameTransform converts "name/TYPE" (e.g. "example.com/A")
34+
// into the internal metadata.name (e.g. "example.com--ips").
35+
func domainRecordNameTransform(arg string) (string, error) {
36+
slash := strings.LastIndex(arg, "/")
37+
if slash == -1 || slash == 0 || slash == len(arg)-1 {
38+
return "", fmt.Errorf("name must be in the form <domain>/<record-type>, e.g. example.com/A")
39+
}
40+
name, typ := arg[:slash], arg[slash+1:]
41+
fieldKey, ok := dnsTypeToFieldKey[strings.ToLower(typ)]
42+
if !ok {
43+
return "", fmt.Errorf("unsupported record type %q; valid types: A, AAAA, CNAME, TXT, MX, DKIM, SPF, DMARC, CAA, SRV, NS, DS, DNSKEY, Ref", typ)
44+
}
45+
return fmt.Sprintf("%s--%s", name, fieldKey), nil
46+
}
47+
48+
var domainRecordResource = &resource.ResourceCommand[*corev1alpha3.DomainRecord, *corev1alpha3.DomainRecordList]{
49+
Use: "domains",
50+
Aliases: []string{"dr", "domainrecord", "domainrecords"},
51+
Short: "Manage domain record objects",
52+
Long: `Domain records configure individual DNS records within a domain zone.`,
53+
KindName: "domainrecord",
54+
ClientFunc: func(c *rest.APIClient) resource.ResourceClient[*corev1alpha3.DomainRecord, *corev1alpha3.DomainRecordList] {
55+
return c.CoreV1alpha3().DomainRecords()
56+
},
57+
TablePrinter: &resource.TablePrinterConfig[*corev1alpha3.DomainRecord, *corev1alpha3.DomainRecordList]{
58+
ObjToTable: func(r *corev1alpha3.DomainRecord) resource.TableConverter { return r },
59+
ListToTable: func(l *corev1alpha3.DomainRecordList) resource.TableConverter { return l },
60+
},
61+
NameTransform: domainRecordNameTransform,
62+
ListFlags: func(cmd *cobra.Command) func() string {
63+
var zone string
64+
cmd.Flags().StringVar(&zone, "zone", "", "Filter domain records by zone name.")
65+
return func() string {
66+
if zone != "" {
67+
return "spec.zone=" + zone
68+
}
69+
return ""
70+
}
71+
},
72+
}

0 commit comments

Comments
 (0)