feat(delete): real deletes — adapters seeded once into adapter_dir (existing dir is authoritative), sources removed from config.yaml on delete; drop tombstone mechanism for both. Docs: multi-key architecture, gateway_keys as seed

This commit is contained in:
root
2026-08-10 11:52:51 +08:00
parent e2dd4d9727
commit f46b02089c
8 changed files with 184 additions and 67 deletions

View File

@ -13,6 +13,7 @@ import (
// Config is the top-level gateway configuration.
type Config struct {
Path string `yaml:"-" json:"-"`
Listen string `yaml:"listen"`
GatewayKeys []string `yaml:"gateway_keys"`
DefaultModel string `yaml:"default_model"` // e.g. "AUTO" or a model id
@ -60,12 +61,63 @@ func Load(path string) (*Config, error) {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
cfg.Path = path
if err := cfg.ApplyDefaults(); err != nil {
return nil, err
}
return &cfg, nil
}
// RemoveSourceFromYAML deletes the named source entry from the config file so
// the delete is a real one (no tombstone needed). Uses yaml.Node to preserve
// the rest of the file's comments and formatting.
func RemoveSourceFromYAML(path, name string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return err
}
content := doc.Content
if len(content) == 0 {
return nil
}
root := content[0]
if root.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(root.Content); i += 2 {
key, val := root.Content[i], root.Content[i+1]
if key.Value != "sources" || val.Kind != yaml.SequenceNode {
continue
}
kept := val.Content[:0]
for _, item := range val.Content {
if item.Kind != yaml.MappingNode {
continue
}
found := false
for j := 0; j+1 < len(item.Content); j += 2 {
if item.Content[j].Value == "name" && item.Content[j+1].Value == name {
found = true
break
}
}
if !found {
kept = append(kept, item)
}
}
val.Content = kept
}
out, err := yaml.Marshal(&doc)
if err != nil {
return err
}
return os.WriteFile(path, out, 0644)
}
// ApplyDefaults sets missing values and validates the config.
func (c *Config) ApplyDefaults() error {
if c.Listen == "" {

View File

@ -196,4 +196,53 @@ func TestSecretBoxRoundTrip(t *testing.T) {
if _, err := bad.Decrypt(v); err == nil {
t.Fatal("expected decrypt failure with wrong key")
}
}
}
func TestRemoveSourceFromYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cfg.yaml")
content := `
listen: 127.0.0.1:9999
gateway_keys: [sk-1]
default_model: AUTO
adapter_dir: adapters
runtime_file: runtime.json
sources:
- name: deepseek
base_url: https://api.deepseek.com
api_key: sk-d
adapter: deepseek
models:
- id: deepseek-v4-flash
- name: ollama
base_url: http://127.0.0.1:11434
adapter: ollama
models:
- id: llama3
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := RemoveSourceFromYAML(path, "deepseek"); err != nil {
t.Fatalf("remove: %v", err)
}
reloaded, err := Load(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if len(reloaded.Sources) != 1 {
t.Fatalf("sources = %d, want 1", len(reloaded.Sources))
}
if reloaded.Sources[0].Name != "ollama" {
t.Fatalf("remaining = %q, want ollama", reloaded.Sources[0].Name)
}
if reloaded.Sources[0].Models[0].ID != "llama3" {
t.Fatalf("remaining models broken: %+v", reloaded.Sources[0].Models)
}
// removing a non-existent name is a no-op that keeps the file valid
if err := RemoveSourceFromYAML(path, "nope"); err != nil {
t.Fatalf("remove missing: %v", err)
}
if cfg, err := Load(path); err != nil || len(cfg.Sources) != 1 {
t.Fatalf("after no-op: %v %v", len(cfg.Sources), err)
}
}

View File

@ -94,7 +94,8 @@ func (s *Store) Upsert(src Source) error {
return s.persistLocked()
}
// Remove deletes a runtime source or hides a base YAML source and persists.
// Remove deletes a runtime source from the store and persists. Base YAML
// sources are handled (truly removed from the config file) by the caller.
func (s *Store) Remove(name string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -108,10 +109,7 @@ func (s *Store) Remove(name string) (bool, error) {
kept = append(kept, src)
}
s.data.Sources = kept
if !containsString(s.data.DeletedSources, name) {
s.data.DeletedSources = append(s.data.DeletedSources, name)
}
return removed || containsString(s.data.DeletedSources, name), s.persistLocked()
return removed, s.persistLocked()
}
func (s *Store) DeletedSources() map[string]bool {
@ -124,32 +122,6 @@ func (s *Store) DeletedSources() map[string]bool {
return out
}
func (s *Store) DeleteAdapter(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !containsString(s.data.DeletedAdapters, name) {
s.data.DeletedAdapters = append(s.data.DeletedAdapters, name)
}
return s.persistLocked()
}
func (s *Store) RestoreAdapter(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data.DeletedAdapters = removeString(s.data.DeletedAdapters, name)
return s.persistLocked()
}
func (s *Store) DeletedAdapters() map[string]bool {
s.mu.Lock()
defer s.mu.Unlock()
out := map[string]bool{}
for _, name := range s.data.DeletedAdapters {
out[name] = true
}
return out
}
func (s *Store) persistLocked() error {
if s.box != nil {
s.encryptLocked()

View File

@ -239,13 +239,9 @@ func (c *Core) Config() *config.Config { return c.cfg }
// mergedSources = base YAML sources + runtime sources (runtime wins by name).
func (c *Core) mergedSources() []config.Source {
deleted := c.store.DeletedSources()
byName := map[string]config.Source{}
order := []string{}
for _, s := range c.cfg.Sources {
if deleted[s.Name] {
continue
}
byName[s.Name] = s
order = append(order, s.Name)
}
@ -313,15 +309,7 @@ func (c *Core) Reload() error {
// ---- adapter management (web UI) ----
func (c *Core) ListAdapters() []lua.APIAdapter {
deleted := c.store.DeletedAdapters()
list := c.vm.ListAdapters()
out := make([]lua.APIAdapter, 0, len(list))
for _, a := range list {
if !deleted[a.Name] {
out = append(out, a)
}
}
return out
return c.vm.ListAdapters()
}
// UploadAdapter saves a new Lua adapter script to the adapter dir and loads it.
@ -336,18 +324,17 @@ func (c *Core) UploadAdapter(name, code string) error {
if err := os.WriteFile(path, []byte(code), 0644); err != nil {
return err
}
if err := c.vm.LoadAdapter(path); err != nil {
return fmt.Errorf("load adapter: %w", err)
}
return c.store.RestoreAdapter(name)
return c.vm.LoadAdapter(path)
}
// RemoveAdapter deletes an adapter script and evicts it from the VM.
// RemoveAdapter deletes an adapter script and evicts it from the VM. The file
// is removed for real (adapter dir is authoritative after first run), so the
// adapter stays gone across restarts.
func (c *Core) RemoveAdapter(name string) error {
path := filepath.Join(c.cfg.AdapterDir, name+".lua")
_ = os.Remove(path)
c.vm.RemoveAdapter(name)
return c.store.DeleteAdapter(name)
return nil
}
// ---- source management (web UI) ----
@ -363,12 +350,31 @@ func (c *Core) AddSource(src config.Source) error {
}
func (c *Core) RemoveSource(name string) error {
for _, s := range c.cfg.Sources {
if s.Name == name {
if err := config.RemoveSourceFromYAML(c.cfg.Path, name); err != nil {
return err
}
c.cfg.Sources = c.removeCfgSource(name)
break
}
}
if _, err := c.store.Remove(name); err != nil {
return err
}
return c.rebuildRegistry()
}
func (c *Core) removeCfgSource(name string) []config.Source {
out := c.cfg.Sources[:0]
for _, s := range c.cfg.Sources {
if s.Name != name {
out = append(out, s)
}
}
return out
}
func (c *Core) Sources() []config.Source { return c.mergedSources() }
func normalizeSource(s *config.Source) error {

View File

@ -183,11 +183,20 @@ func (v *VM) Start() error {
if v.dir == "" {
return nil
}
if err := os.MkdirAll(v.dir, 0755); err != nil {
return fmt.Errorf("mkdir adapter dir: %w", err)
// First-run seeding: a brand-new adapter dir is created and populated with
// the bundled adapters. If the dir already exists it is treated as
// authoritative and never rewritten — deleting a file there is a real delete.
firstRun := false
if _, err := os.Stat(v.dir); os.IsNotExist(err) {
firstRun = true
if err := os.MkdirAll(v.dir, 0755); err != nil {
return fmt.Errorf("mkdir adapter dir: %w", err)
}
}
if err := v.writeBundledAdapters(); err != nil {
return err
if firstRun {
if err := v.writeBundledAdapters(); err != nil {
return err
}
}
entries, err := os.ReadDir(v.dir)
if err != nil {