feat(remote): add durable catalog cache and identity
- commit
- f0e21e9ee512efd75033c27408c8c3acd8061e4f
- author
- brice · · 2026-07-30 14:45 UTC
- committer
- brice · · 2026-07-31 08:54 UTC
- parent
- 3776805
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +1115 -10
A internal/adapters/remote/catalog_cache.go +207 -0
| @@ -0,0 +1,207 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "sort" | |
| 10 | + | "time" | |
| 11 | + | "unicode/utf8" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const catalogCacheFileVersion = 1 | |
| 18 | + | ||
| 19 | + | type catalogCacheFile struct { | |
| 20 | + | Version *int `json:"version"` | |
| 21 | + | Hosts *[]catalogCacheHost `json:"hosts"` | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type catalogCacheHost struct { | |
| 25 | + | Target *string `json:"target"` | |
| 26 | + | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |
| 27 | + | Sessions *[]catalogCacheSession `json:"sessions"` | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type catalogCacheSession struct { | |
| 31 | + | Name *string `json:"name"` | |
| 32 | + | State *string `json:"state"` | |
| 33 | + | Ephemeral *bool `json:"ephemeral"` | |
| 34 | + | Tabs *uint16 `json:"tabs"` | |
| 35 | + | Attached *bool `json:"attached"` | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | type fileCatalogCache struct { | |
| 39 | + | path string | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // NewFileCatalogCache returns a durable remote catalog cache backed by path. | |
| 43 | + | func NewFileCatalogCache(path string) ports.RemoteCatalogCache { | |
| 44 | + | return &fileCatalogCache{path: path} | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil) | |
| 48 | + | ||
| 49 | + | // Load reads and validates one complete cache snapshot. A missing cache is an | |
| 50 | + | // empty snapshot; malformed cache data is never changed by Load. | |
| 51 | + | func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |
| 52 | + | raw, err := os.ReadFile(c.path) | |
| 53 | + | if errors.Is(err, os.ErrNotExist) { | |
| 54 | + | return []ports.RemoteCatalogCacheEntry{}, nil | |
| 55 | + | } | |
| 56 | + | if err != nil { | |
| 57 | + | return nil, err | |
| 58 | + | } | |
| 59 | + | if !utf8.Valid(raw) { | |
| 60 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | dec := json.NewDecoder(bytes.NewReader(raw)) | |
| 64 | + | dec.DisallowUnknownFields() | |
| 65 | + | var file catalogCacheFile | |
| 66 | + | if err := dec.Decode(&file); err != nil { | |
| 67 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |
| 68 | + | } | |
| 69 | + | if err := rejectTrailingJSON(dec); err != nil { | |
| 70 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |
| 71 | + | } | |
| 72 | + | if file.Version == nil { | |
| 73 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version") | |
| 74 | + | } | |
| 75 | + | if *file.Version != catalogCacheFileVersion { | |
| 76 | + | return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *file.Version) | |
| 77 | + | } | |
| 78 | + | if file.Hosts == nil { | |
| 79 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | entries := make([]ports.RemoteCatalogCacheEntry, 0, len(*file.Hosts)) | |
| 83 | + | for _, host := range *file.Hosts { | |
| 84 | + | if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil { | |
| 85 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields") | |
| 86 | + | } | |
| 87 | + | if !utf8.ValidString(*host.Target) { | |
| 88 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |
| 89 | + | } | |
| 90 | + | if *host.FetchedAtUnixNano <= 0 { | |
| 91 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time") | |
| 92 | + | } | |
| 93 | + | entry := ports.RemoteCatalogCacheEntry{ | |
| 94 | + | Host: *host.Target, | |
| 95 | + | FetchedAt: time.Unix(0, *host.FetchedAtUnixNano), | |
| 96 | + | Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)), | |
| 97 | + | } | |
| 98 | + | for _, session := range *host.Sessions { | |
| 99 | + | if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil { | |
| 100 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields") | |
| 101 | + | } | |
| 102 | + | if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) { | |
| 103 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |
| 104 | + | } | |
| 105 | + | entry.Sessions = append(entry.Sessions, ports.RemoteCatalogSession{ | |
| 106 | + | Name: *session.Name, | |
| 107 | + | State: *session.State, | |
| 108 | + | Ephemeral: *session.Ephemeral, | |
| 109 | + | Tabs: *session.Tabs, | |
| 110 | + | Attached: *session.Attached, | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | entries = append(entries, entry) | |
| 114 | + | } | |
| 115 | + | normalized, err := normalizeCatalogCacheEntries(entries) | |
| 116 | + | if err != nil { | |
| 117 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |
| 118 | + | } | |
| 119 | + | return normalized, nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Store atomically replaces the cache with a complete normalized snapshot. | |
| 123 | + | func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error { | |
| 124 | + | normalized, err := normalizeCatalogCacheEntries(entries) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | hosts := make([]catalogCacheHost, 0, len(normalized)) | |
| 129 | + | for _, entry := range normalized { | |
| 130 | + | target := entry.Host | |
| 131 | + | fetchedAt := entry.FetchedAt.UnixNano() | |
| 132 | + | sessions := make([]catalogCacheSession, 0, len(entry.Sessions)) | |
| 133 | + | for _, session := range entry.Sessions { | |
| 134 | + | name := session.Name | |
| 135 | + | state := session.State | |
| 136 | + | ephemeral := session.Ephemeral | |
| 137 | + | tabs := session.Tabs | |
| 138 | + | attached := session.Attached | |
| 139 | + | sessions = append(sessions, catalogCacheSession{ | |
| 140 | + | Name: &name, | |
| 141 | + | State: &state, | |
| 142 | + | Ephemeral: &ephemeral, | |
| 143 | + | Tabs: &tabs, | |
| 144 | + | Attached: &attached, | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | hosts = append(hosts, catalogCacheHost{ | |
| 148 | + | Target: &target, | |
| 149 | + | FetchedAtUnixNano: &fetchedAt, | |
| 150 | + | Sessions: &sessions, | |
| 151 | + | }) | |
| 152 | + | } | |
| 153 | + | version := catalogCacheFileVersion | |
| 154 | + | payload, err := json.Marshal(catalogCacheFile{Version: &version, Hosts: &hosts}) | |
| 155 | + | if err != nil { | |
| 156 | + | return err | |
| 157 | + | } | |
| 158 | + | return atomicReplacePrivateFile(c.path, append(payload, '\n')) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) { | |
| 162 | + | normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries)) | |
| 163 | + | hosts := make(map[string]struct{}, len(entries)) | |
| 164 | + | for _, entry := range entries { | |
| 165 | + | if err := validateHostTarget(entry.Host); err != nil { | |
| 166 | + | return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err) | |
| 167 | + | } | |
| 168 | + | if entry.FetchedAt.UnixNano() <= 0 { | |
| 169 | + | return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host) | |
| 170 | + | } | |
| 171 | + | if _, duplicate := hosts[entry.Host]; duplicate { | |
| 172 | + | return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host) | |
| 173 | + | } | |
| 174 | + | hosts[entry.Host] = struct{}{} | |
| 175 | + | ||
| 176 | + | copyEntry := ports.RemoteCatalogCacheEntry{ | |
| 177 | + | Host: entry.Host, | |
| 178 | + | FetchedAt: entry.FetchedAt, | |
| 179 | + | Sessions: make([]ports.RemoteCatalogSession, 0, len(entry.Sessions)), | |
| 180 | + | } | |
| 181 | + | sessions := make(map[string]struct{}, len(entry.Sessions)) | |
| 182 | + | for _, session := range entry.Sessions { | |
| 183 | + | if !utf8.ValidString(session.Name) || !utf8.ValidString(session.State) { | |
| 184 | + | return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8") | |
| 185 | + | } | |
| 186 | + | if err := domain.ValidateSessionName(session.Name); err != nil { | |
| 187 | + | return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err) | |
| 188 | + | } | |
| 189 | + | if !validCatalogState(session.State) { | |
| 190 | + | return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State) | |
| 191 | + | } | |
| 192 | + | if _, duplicate := sessions[session.Name]; duplicate { | |
| 193 | + | return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host) | |
| 194 | + | } | |
| 195 | + | sessions[session.Name] = struct{}{} | |
| 196 | + | copyEntry.Sessions = append(copyEntry.Sessions, session) | |
| 197 | + | } | |
| 198 | + | sort.Slice(copyEntry.Sessions, func(i, j int) bool { | |
| 199 | + | return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name | |
| 200 | + | }) | |
| 201 | + | normalized = append(normalized, copyEntry) | |
| 202 | + | } | |
| 203 | + | sort.Slice(normalized, func(i, j int) bool { | |
| 204 | + | return normalized[i].Host < normalized[j].Host | |
| 205 | + | }) | |
| 206 | + | return normalized, nil | |
| 207 | + | } | |
| @@ -0,0 +1,207 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "sort" | |||
| 10 | + | "time" | |||
| 11 | + | "unicode/utf8" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const catalogCacheFileVersion = 1 | |||
| 18 | + | ||||
| 19 | + | type catalogCacheFile struct { | |||
| 20 | + | Version *int `json:"version"` | |||
| 21 | + | Hosts *[]catalogCacheHost `json:"hosts"` | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type catalogCacheHost struct { | |||
| 25 | + | Target *string `json:"target"` | |||
| 26 | + | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |||
| 27 | + | Sessions *[]catalogCacheSession `json:"sessions"` | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type catalogCacheSession struct { | |||
| 31 | + | Name *string `json:"name"` | |||
| 32 | + | State *string `json:"state"` | |||
| 33 | + | Ephemeral *bool `json:"ephemeral"` | |||
| 34 | + | Tabs *uint16 `json:"tabs"` | |||
| 35 | + | Attached *bool `json:"attached"` | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | type fileCatalogCache struct { | |||
| 39 | + | path string | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // NewFileCatalogCache returns a durable remote catalog cache backed by path. | |||
| 43 | + | func NewFileCatalogCache(path string) ports.RemoteCatalogCache { | |||
| 44 | + | return &fileCatalogCache{path: path} | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil) | |||
| 48 | + | ||||
| 49 | + | // Load reads and validates one complete cache snapshot. A missing cache is an | |||
| 50 | + | // empty snapshot; malformed cache data is never changed by Load. | |||
| 51 | + | func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 52 | + | raw, err := os.ReadFile(c.path) | |||
| 53 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 54 | + | return []ports.RemoteCatalogCacheEntry{}, nil | |||
| 55 | + | } | |||
| 56 | + | if err != nil { | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | if !utf8.Valid(raw) { | |||
| 60 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | dec := json.NewDecoder(bytes.NewReader(raw)) | |||
| 64 | + | dec.DisallowUnknownFields() | |||
| 65 | + | var file catalogCacheFile | |||
| 66 | + | if err := dec.Decode(&file); err != nil { | |||
| 67 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |||
| 68 | + | } | |||
| 69 | + | if err := rejectTrailingJSON(dec); err != nil { | |||
| 70 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |||
| 71 | + | } | |||
| 72 | + | if file.Version == nil { | |||
| 73 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version") | |||
| 74 | + | } | |||
| 75 | + | if *file.Version != catalogCacheFileVersion { | |||
| 76 | + | return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *file.Version) | |||
| 77 | + | } | |||
| 78 | + | if file.Hosts == nil { | |||
| 79 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts") | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | entries := make([]ports.RemoteCatalogCacheEntry, 0, len(*file.Hosts)) | |||
| 83 | + | for _, host := range *file.Hosts { | |||
| 84 | + | if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil { | |||
| 85 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields") | |||
| 86 | + | } | |||
| 87 | + | if !utf8.ValidString(*host.Target) { | |||
| 88 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |||
| 89 | + | } | |||
| 90 | + | if *host.FetchedAtUnixNano <= 0 { | |||
| 91 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time") | |||
| 92 | + | } | |||
| 93 | + | entry := ports.RemoteCatalogCacheEntry{ | |||
| 94 | + | Host: *host.Target, | |||
| 95 | + | FetchedAt: time.Unix(0, *host.FetchedAtUnixNano), | |||
| 96 | + | Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)), | |||
| 97 | + | } | |||
| 98 | + | for _, session := range *host.Sessions { | |||
| 99 | + | if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil { | |||
| 100 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields") | |||
| 101 | + | } | |||
| 102 | + | if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) { | |||
| 103 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |||
| 104 | + | } | |||
| 105 | + | entry.Sessions = append(entry.Sessions, ports.RemoteCatalogSession{ | |||
| 106 | + | Name: *session.Name, | |||
| 107 | + | State: *session.State, | |||
| 108 | + | Ephemeral: *session.Ephemeral, | |||
| 109 | + | Tabs: *session.Tabs, | |||
| 110 | + | Attached: *session.Attached, | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | entries = append(entries, entry) | |||
| 114 | + | } | |||
| 115 | + | normalized, err := normalizeCatalogCacheEntries(entries) | |||
| 116 | + | if err != nil { | |||
| 117 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |||
| 118 | + | } | |||
| 119 | + | return normalized, nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // Store atomically replaces the cache with a complete normalized snapshot. | |||
| 123 | + | func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error { | |||
| 124 | + | normalized, err := normalizeCatalogCacheEntries(entries) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | hosts := make([]catalogCacheHost, 0, len(normalized)) | |||
| 129 | + | for _, entry := range normalized { | |||
| 130 | + | target := entry.Host | |||
| 131 | + | fetchedAt := entry.FetchedAt.UnixNano() | |||
| 132 | + | sessions := make([]catalogCacheSession, 0, len(entry.Sessions)) | |||
| 133 | + | for _, session := range entry.Sessions { | |||
| 134 | + | name := session.Name | |||
| 135 | + | state := session.State | |||
| 136 | + | ephemeral := session.Ephemeral | |||
| 137 | + | tabs := session.Tabs | |||
| 138 | + | attached := session.Attached | |||
| 139 | + | sessions = append(sessions, catalogCacheSession{ | |||
| 140 | + | Name: &name, | |||
| 141 | + | State: &state, | |||
| 142 | + | Ephemeral: &ephemeral, | |||
| 143 | + | Tabs: &tabs, | |||
| 144 | + | Attached: &attached, | |||
| 145 | + | }) | |||
| 146 | + | } | |||
| 147 | + | hosts = append(hosts, catalogCacheHost{ | |||
| 148 | + | Target: &target, | |||
| 149 | + | FetchedAtUnixNano: &fetchedAt, | |||
| 150 | + | Sessions: &sessions, | |||
| 151 | + | }) | |||
| 152 | + | } | |||
| 153 | + | version := catalogCacheFileVersion | |||
| 154 | + | payload, err := json.Marshal(catalogCacheFile{Version: &version, Hosts: &hosts}) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | return atomicReplacePrivateFile(c.path, append(payload, '\n')) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 162 | + | normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries)) | |||
| 163 | + | hosts := make(map[string]struct{}, len(entries)) | |||
| 164 | + | for _, entry := range entries { | |||
| 165 | + | if err := validateHostTarget(entry.Host); err != nil { | |||
| 166 | + | return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err) | |||
| 167 | + | } | |||
| 168 | + | if entry.FetchedAt.UnixNano() <= 0 { | |||
| 169 | + | return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host) | |||
| 170 | + | } | |||
| 171 | + | if _, duplicate := hosts[entry.Host]; duplicate { | |||
| 172 | + | return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host) | |||
| 173 | + | } | |||
| 174 | + | hosts[entry.Host] = struct{}{} | |||
| 175 | + | ||||
| 176 | + | copyEntry := ports.RemoteCatalogCacheEntry{ | |||
| 177 | + | Host: entry.Host, | |||
| 178 | + | FetchedAt: entry.FetchedAt, | |||
| 179 | + | Sessions: make([]ports.RemoteCatalogSession, 0, len(entry.Sessions)), | |||
| 180 | + | } | |||
| 181 | + | sessions := make(map[string]struct{}, len(entry.Sessions)) | |||
| 182 | + | for _, session := range entry.Sessions { | |||
| 183 | + | if !utf8.ValidString(session.Name) || !utf8.ValidString(session.State) { | |||
| 184 | + | return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8") | |||
| 185 | + | } | |||
| 186 | + | if err := domain.ValidateSessionName(session.Name); err != nil { | |||
| 187 | + | return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err) | |||
| 188 | + | } | |||
| 189 | + | if !validCatalogState(session.State) { | |||
| 190 | + | return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State) | |||
| 191 | + | } | |||
| 192 | + | if _, duplicate := sessions[session.Name]; duplicate { | |||
| 193 | + | return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host) | |||
| 194 | + | } | |||
| 195 | + | sessions[session.Name] = struct{}{} | |||
| 196 | + | copyEntry.Sessions = append(copyEntry.Sessions, session) | |||
| 197 | + | } | |||
| 198 | + | sort.Slice(copyEntry.Sessions, func(i, j int) bool { | |||
| 199 | + | return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name | |||
| 200 | + | }) | |||
| 201 | + | normalized = append(normalized, copyEntry) | |||
| 202 | + | } | |||
| 203 | + | sort.Slice(normalized, func(i, j int) bool { | |||
| 204 | + | return normalized[i].Host < normalized[j].Host | |||
| 205 | + | }) | |||
| 206 | + | return normalized, nil | |||
| 207 | + | } | |||
A internal/adapters/remote/catalog_cache_test.go +264 -0
| @@ -0,0 +1,264 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "slices" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestCatalogCacheStoreLoad(t *testing.T) { | |
| 15 | + | t.Parallel() | |
| 16 | + | ||
| 17 | + | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 18 | + | cache := NewFileCatalogCache(path) | |
| 19 | + | fetchedAt := time.Unix(0, 1780000000000000000) | |
| 20 | + | entries := []ports.RemoteCatalogCacheEntry{ | |
| 21 | + | { | |
| 22 | + | Host: "zebra", | |
| 23 | + | FetchedAt: fetchedAt, | |
| 24 | + | Sessions: []ports.RemoteCatalogSession{ | |
| 25 | + | {Name: "work", State: "running", Tabs: 2}, | |
| 26 | + | {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true}, | |
| 27 | + | }, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | Host: "arch", | |
| 31 | + | FetchedAt: fetchedAt.Add(time.Second), | |
| 32 | + | Sessions: []ports.RemoteCatalogSession{}, | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | if err := cache.Store(entries); err != nil { | |
| 37 | + | t.Fatalf("Store() error = %v", err) | |
| 38 | + | } | |
| 39 | + | raw, err := os.ReadFile(path) | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatalf("read cache: %v", err) | |
| 42 | + | } | |
| 43 | + | arch := strings.Index(string(raw), `"target":"arch"`) | |
| 44 | + | zebra := strings.Index(string(raw), `"target":"zebra"`) | |
| 45 | + | if arch < 0 || zebra < 0 || arch >= zebra { | |
| 46 | + | t.Fatalf("stored hosts are not ordered: %s", raw) | |
| 47 | + | } | |
| 48 | + | alpha := strings.Index(string(raw), `"name":"alpha"`) | |
| 49 | + | work := strings.Index(string(raw), `"name":"work"`) | |
| 50 | + | if alpha < 0 || work < 0 || alpha >= work { | |
| 51 | + | t.Fatalf("stored sessions are not ordered: %s", raw) | |
| 52 | + | } | |
| 53 | + | got, err := cache.Load() | |
| 54 | + | if err != nil { | |
| 55 | + | t.Fatalf("Load() error = %v", err) | |
| 56 | + | } | |
| 57 | + | want := []ports.RemoteCatalogCacheEntry{ | |
| 58 | + | { | |
| 59 | + | Host: "arch", | |
| 60 | + | FetchedAt: fetchedAt.Add(time.Second), | |
| 61 | + | Sessions: []ports.RemoteCatalogSession{}, | |
| 62 | + | }, | |
| 63 | + | { | |
| 64 | + | Host: "zebra", | |
| 65 | + | FetchedAt: fetchedAt, | |
| 66 | + | Sessions: []ports.RemoteCatalogSession{ | |
| 67 | + | {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true}, | |
| 68 | + | {Name: "work", State: "running", Tabs: 2}, | |
| 69 | + | }, | |
| 70 | + | }, | |
| 71 | + | } | |
| 72 | + | if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) { | |
| 73 | + | t.Fatalf("Load() = %#v, want %#v", got, want) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | info, err := os.Stat(path) | |
| 77 | + | if err != nil { | |
| 78 | + | t.Fatalf("stat cache: %v", err) | |
| 79 | + | } | |
| 80 | + | if perm := info.Mode().Perm(); perm != 0o600 { | |
| 81 | + | t.Fatalf("cache permissions = %04o, want 0600", perm) | |
| 82 | + | } | |
| 83 | + | dirInfo, err := os.Lstat(filepath.Dir(path)) | |
| 84 | + | if err != nil { | |
| 85 | + | t.Fatalf("stat cache directory: %v", err) | |
| 86 | + | } | |
| 87 | + | if perm := dirInfo.Mode().Perm(); perm != 0o700 { | |
| 88 | + | t.Fatalf("cache directory permissions = %04o, want 0700", perm) | |
| 89 | + | } | |
| 90 | + | entriesOnDisk, err := os.ReadDir(filepath.Dir(path)) | |
| 91 | + | if err != nil { | |
| 92 | + | t.Fatalf("read cache directory: %v", err) | |
| 93 | + | } | |
| 94 | + | for _, entry := range entriesOnDisk { | |
| 95 | + | if strings.Contains(entry.Name(), ".tmp") { | |
| 96 | + | t.Fatalf("temporary file left behind: %q", entry.Name()) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | |
| 102 | + | t.Parallel() | |
| 103 | + | ||
| 104 | + | cases := []struct { | |
| 105 | + | name string | |
| 106 | + | raw []byte | |
| 107 | + | }{ | |
| 108 | + | {name: "truncated", raw: []byte(`{"version":1,"hosts":[`)}, | |
| 109 | + | {name: "trailing JSON", raw: []byte(`{"version":1,"hosts":[]} {}`)}, | |
| 110 | + | {name: "unknown version", raw: []byte(`{"version":2,"hosts":[]}`)}, | |
| 111 | + | {name: "missing hosts", raw: []byte(`{"version":1}`)}, | |
| 112 | + | {name: "null hosts", raw: []byte(`{"version":1,"hosts":null}`)}, | |
| 113 | + | {name: "null sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)}, | |
| 114 | + | {name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)}, | |
| 115 | + | {name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)}, | |
| 116 | + | {name: "duplicate hosts", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)}, | |
| 117 | + | {name: "duplicate sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"running","ephemeral":false,"tabs":1,"attached":false},{"name":"work","state":"running","ephemeral":false,"tabs":1,"attached":false}]}]}`)}, | |
| 118 | + | {name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")}, | |
| 119 | + | } | |
| 120 | + | for _, tc := range cases { | |
| 121 | + | t.Run(tc.name, func(t *testing.T) { | |
| 122 | + | t.Parallel() | |
| 123 | + | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 124 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 125 | + | t.Fatalf("mkdir: %v", err) | |
| 126 | + | } | |
| 127 | + | if err := os.WriteFile(path, tc.raw, 0o600); err != nil { | |
| 128 | + | t.Fatalf("write cache: %v", err) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | if _, err := NewFileCatalogCache(path).Load(); err == nil { | |
| 132 | + | t.Fatal("Load() error = nil, want malformed cache error") | |
| 133 | + | } | |
| 134 | + | after, err := os.ReadFile(path) | |
| 135 | + | if err != nil { | |
| 136 | + | t.Fatalf("read cache: %v", err) | |
| 137 | + | } | |
| 138 | + | if !slices.Equal(after, tc.raw) { | |
| 139 | + | t.Fatalf("invalid cache changed: got %q, want %q", after, tc.raw) | |
| 140 | + | } | |
| 141 | + | }) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) { | |
| 146 | + | t.Parallel() | |
| 147 | + | ||
| 148 | + | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 149 | + | cache := NewFileCatalogCache(path) | |
| 150 | + | valid := []ports.RemoteCatalogCacheEntry{{ | |
| 151 | + | Host: "arch", | |
| 152 | + | FetchedAt: time.Unix(0, 1), | |
| 153 | + | Sessions: []ports.RemoteCatalogSession{}, | |
| 154 | + | }} | |
| 155 | + | if err := cache.Store(valid); err != nil { | |
| 156 | + | t.Fatalf("initial Store() error = %v", err) | |
| 157 | + | } | |
| 158 | + | before, err := os.ReadFile(path) | |
| 159 | + | if err != nil { | |
| 160 | + | t.Fatalf("read initial cache: %v", err) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | invalid := []ports.RemoteCatalogCacheEntry{{ | |
| 164 | + | Host: "arch", | |
| 165 | + | FetchedAt: time.Time{}, | |
| 166 | + | Sessions: []ports.RemoteCatalogSession{}, | |
| 167 | + | }} | |
| 168 | + | if err := cache.Store(invalid); err == nil { | |
| 169 | + | t.Fatal("Store() error = nil, want validation error") | |
| 170 | + | } | |
| 171 | + | after, err := os.ReadFile(path) | |
| 172 | + | if err != nil { | |
| 173 | + | t.Fatalf("read cache after failed Store: %v", err) | |
| 174 | + | } | |
| 175 | + | if !slices.Equal(after, before) { | |
| 176 | + | t.Fatalf("failed Store changed cache: got %q, want %q", after, before) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestCatalogCacheStoreRejectsNonPositiveFetchedTimes(t *testing.T) { | |
| 181 | + | t.Parallel() | |
| 182 | + | ||
| 183 | + | for _, tc := range []struct { | |
| 184 | + | name string | |
| 185 | + | fetchedAt time.Time | |
| 186 | + | }{ | |
| 187 | + | {name: "zero Unix nano", fetchedAt: time.Unix(0, 0)}, | |
| 188 | + | {name: "negative Unix nano", fetchedAt: time.Unix(0, -1)}, | |
| 189 | + | } { | |
| 190 | + | t.Run(tc.name, func(t *testing.T) { | |
| 191 | + | t.Parallel() | |
| 192 | + | path := filepath.Join(t.TempDir(), "remote-catalog-cache.json") | |
| 193 | + | entries := []ports.RemoteCatalogCacheEntry{{ | |
| 194 | + | Host: "arch", | |
| 195 | + | FetchedAt: tc.fetchedAt, | |
| 196 | + | Sessions: []ports.RemoteCatalogSession{}, | |
| 197 | + | }} | |
| 198 | + | ||
| 199 | + | err := NewFileCatalogCache(path).Store(entries) | |
| 200 | + | ||
| 201 | + | if err == nil { | |
| 202 | + | t.Fatal("Store() error = nil, want non-positive fetched time error") | |
| 203 | + | } | |
| 204 | + | if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { | |
| 205 | + | t.Fatalf("failed Store created cache: stat error = %v", statErr) | |
| 206 | + | } | |
| 207 | + | }) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestCatalogCacheStoreHardensExistingFilePermissions(t *testing.T) { | |
| 212 | + | t.Parallel() | |
| 213 | + | ||
| 214 | + | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 215 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 216 | + | t.Fatalf("mkdir: %v", err) | |
| 217 | + | } | |
| 218 | + | if err := os.WriteFile(path, []byte(`{"version":1,"hosts":[]}`), 0o644); err != nil { | |
| 219 | + | t.Fatalf("seed cache: %v", err) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | cache := NewFileCatalogCache(path) | |
| 223 | + | if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err != nil { | |
| 224 | + | t.Fatalf("Store() error = %v", err) | |
| 225 | + | } | |
| 226 | + | info, err := os.Stat(path) | |
| 227 | + | if err != nil { | |
| 228 | + | t.Fatalf("stat cache: %v", err) | |
| 229 | + | } | |
| 230 | + | if perm := info.Mode().Perm(); perm != 0o600 { | |
| 231 | + | t.Fatalf("cache permissions = %04o, want 0600", perm) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) { | |
| 236 | + | t.Parallel() | |
| 237 | + | ||
| 238 | + | dir := filepath.Join(t.TempDir(), "vev") | |
| 239 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 240 | + | t.Fatalf("mkdir parent: %v", err) | |
| 241 | + | } | |
| 242 | + | path := filepath.Join(dir, "remote-catalog-cache.json") | |
| 243 | + | if err := os.Mkdir(path, 0o700); err != nil { | |
| 244 | + | t.Fatalf("mkdir destination: %v", err) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | cache := NewFileCatalogCache(path) | |
| 248 | + | if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err == nil { | |
| 249 | + | t.Fatal("Store() error = nil, want rename error") | |
| 250 | + | } | |
| 251 | + | entries, err := os.ReadDir(dir) | |
| 252 | + | if err != nil { | |
| 253 | + | t.Fatalf("read cache directory: %v", err) | |
| 254 | + | } | |
| 255 | + | for _, entry := range entries { | |
| 256 | + | if strings.Contains(entry.Name(), ".tmp") { | |
| 257 | + | t.Fatalf("temporary file left behind after failed rename: %q", entry.Name()) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool { | |
| 263 | + | return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && slices.Equal(a.Sessions, b.Sessions) | |
| 264 | + | } | |
| @@ -0,0 +1,264 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "slices" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestCatalogCacheStoreLoad(t *testing.T) { | |||
| 15 | + | t.Parallel() | |||
| 16 | + | ||||
| 17 | + | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |||
| 18 | + | cache := NewFileCatalogCache(path) | |||
| 19 | + | fetchedAt := time.Unix(0, 1780000000000000000) | |||
| 20 | + | entries := []ports.RemoteCatalogCacheEntry{ | |||
| 21 | + | { | |||
| 22 | + | Host: "zebra", | |||
| 23 | + | FetchedAt: fetchedAt, | |||
| 24 | + | Sessions: []ports.RemoteCatalogSession{ | |||
| 25 | + | {Name: "work", State: "running", Tabs: 2}, | |||
| 26 | + | {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true}, | |||
| 27 | + | }, | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | Host: "arch", | |||
| 31 | + | FetchedAt: fetchedAt.Add(time.Second), | |||
| 32 | + | Sessions: []ports.RemoteCatalogSession{}, | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | if err := cache.Store(entries); err != nil { | |||
| 37 | + | t.Fatalf("Store() error = %v", err) | |||
| 38 | + | } | |||
| 39 | + | raw, err := os.ReadFile(path) | |||
| 40 | + | if err != nil { | |||
| 41 | + | t.Fatalf("read cache: %v", err) | |||
| 42 | + | } | |||
| 43 | + | arch := strings.Index(string(raw), `"target":"arch"`) | |||
| 44 | + | zebra := strings.Index(string(raw), `"target":"zebra"`) | |||
| 45 | + | if arch < 0 || zebra < 0 || arch >= zebra { | |||
| 46 | + | t.Fatalf("stored hosts are not ordered: %s", raw) | |||
| 47 | + | } | |||
| 48 | + | alpha := strings.Index(string(raw), `"name":"alpha"`) | |||
| 49 | + | work := strings.Index(string(raw), `"name":"work"`) | |||
| 50 | + | if alpha < 0 || work < 0 || alpha >= work { | |||
| 51 | + | t.Fatalf("stored sessions are not ordered: %s", raw) | |||
| 52 | + | } | |||
| 53 | + | got, err := cache.Load() | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatalf("Load() error = %v", err) | |||
| 56 | + | } | |||
| 57 | + | want := []ports.RemoteCatalogCacheEntry{ | |||
| 58 | + | { | |||
| 59 | + | Host: "arch", | |||
| 60 | + | FetchedAt: fetchedAt.Add(time.Second), | |||
| 61 | + | Sessions: []ports.RemoteCatalogSession{}, | |||
| 62 | + | }, | |||
| 63 | + | { | |||
| 64 | + | Host: "zebra", | |||
| 65 | + | FetchedAt: fetchedAt, | |||
| 66 | + | Sessions: []ports.RemoteCatalogSession{ | |||
| 67 | + | {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true}, | |||
| 68 | + | {Name: "work", State: "running", Tabs: 2}, | |||
| 69 | + | }, | |||
| 70 | + | }, | |||
| 71 | + | } | |||
| 72 | + | if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) { | |||
| 73 | + | t.Fatalf("Load() = %#v, want %#v", got, want) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | info, err := os.Stat(path) | |||
| 77 | + | if err != nil { | |||
| 78 | + | t.Fatalf("stat cache: %v", err) | |||
| 79 | + | } | |||
| 80 | + | if perm := info.Mode().Perm(); perm != 0o600 { | |||
| 81 | + | t.Fatalf("cache permissions = %04o, want 0600", perm) | |||
| 82 | + | } | |||
| 83 | + | dirInfo, err := os.Lstat(filepath.Dir(path)) | |||
| 84 | + | if err != nil { | |||
| 85 | + | t.Fatalf("stat cache directory: %v", err) | |||
| 86 | + | } | |||
| 87 | + | if perm := dirInfo.Mode().Perm(); perm != 0o700 { | |||
| 88 | + | t.Fatalf("cache directory permissions = %04o, want 0700", perm) | |||
| 89 | + | } | |||
| 90 | + | entriesOnDisk, err := os.ReadDir(filepath.Dir(path)) | |||
| 91 | + | if err != nil { | |||
| 92 | + | t.Fatalf("read cache directory: %v", err) | |||
| 93 | + | } | |||
| 94 | + | for _, entry := range entriesOnDisk { | |||
| 95 | + | if strings.Contains(entry.Name(), ".tmp") { | |||
| 96 | + | t.Fatalf("temporary file left behind: %q", entry.Name()) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | |||
| 102 | + | t.Parallel() | |||
| 103 | + | ||||
| 104 | + | cases := []struct { | |||
| 105 | + | name string | |||
| 106 | + | raw []byte | |||
| 107 | + | }{ | |||
| 108 | + | {name: "truncated", raw: []byte(`{"version":1,"hosts":[`)}, | |||
| 109 | + | {name: "trailing JSON", raw: []byte(`{"version":1,"hosts":[]} {}`)}, | |||
| 110 | + | {name: "unknown version", raw: []byte(`{"version":2,"hosts":[]}`)}, | |||
| 111 | + | {name: "missing hosts", raw: []byte(`{"version":1}`)}, | |||
| 112 | + | {name: "null hosts", raw: []byte(`{"version":1,"hosts":null}`)}, | |||
| 113 | + | {name: "null sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)}, | |||
| 114 | + | {name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)}, | |||
| 115 | + | {name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)}, | |||
| 116 | + | {name: "duplicate hosts", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)}, | |||
| 117 | + | {name: "duplicate sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"running","ephemeral":false,"tabs":1,"attached":false},{"name":"work","state":"running","ephemeral":false,"tabs":1,"attached":false}]}]}`)}, | |||
| 118 | + | {name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")}, | |||
| 119 | + | } | |||
| 120 | + | for _, tc := range cases { | |||
| 121 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 122 | + | t.Parallel() | |||
| 123 | + | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |||
| 124 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |||
| 125 | + | t.Fatalf("mkdir: %v", err) | |||
| 126 | + | } | |||
| 127 | + | if err := os.WriteFile(path, tc.raw, 0o600); err != nil { | |||
| 128 | + | t.Fatalf("write cache: %v", err) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | if _, err := NewFileCatalogCache(path).Load(); err == nil { | |||
| 132 | + | t.Fatal("Load() error = nil, want malformed cache error") | |||
| 133 | + | } | |||
| 134 | + | after, err := os.ReadFile(path) | |||
| 135 | + | if err != nil { | |||
| 136 | + | t.Fatalf("read cache: %v", err) | |||
| 137 | + | } | |||
| 138 | + | if !slices.Equal(after, tc.raw) { | |||
| 139 | + | t.Fatalf("invalid cache changed: got %q, want %q", after, tc.raw) | |||
| 140 | + | } | |||
| 141 | + | }) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) { | |||
| 146 | + | t.Parallel() | |||
| 147 | + | ||||
| 148 | + | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |||
| 149 | + | cache := NewFileCatalogCache(path) | |||
| 150 | + | valid := []ports.RemoteCatalogCacheEntry{{ | |||
| 151 | + | Host: "arch", | |||
| 152 | + | FetchedAt: time.Unix(0, 1), | |||
| 153 | + | Sessions: []ports.RemoteCatalogSession{}, | |||
| 154 | + | }} | |||
| 155 | + | if err := cache.Store(valid); err != nil { | |||
| 156 | + | t.Fatalf("initial Store() error = %v", err) | |||
| 157 | + | } | |||
| 158 | + | before, err := os.ReadFile(path) | |||
| 159 | + | if err != nil { | |||
| 160 | + | t.Fatalf("read initial cache: %v", err) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | invalid := []ports.RemoteCatalogCacheEntry{{ | |||
| 164 | + | Host: "arch", | |||
| 165 | + | FetchedAt: time.Time{}, | |||
| 166 | + | Sessions: []ports.RemoteCatalogSession{}, | |||
| 167 | + | }} | |||
| 168 | + | if err := cache.Store(invalid); err == nil { | |||
| 169 | + | t.Fatal("Store() error = nil, want validation error") | |||
| 170 | + | } | |||
| 171 | + | after, err := os.ReadFile(path) | |||
| 172 | + | if err != nil { | |||
| 173 | + | t.Fatalf("read cache after failed Store: %v", err) | |||
| 174 | + | } | |||
| 175 | + | if !slices.Equal(after, before) { | |||
| 176 | + | t.Fatalf("failed Store changed cache: got %q, want %q", after, before) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestCatalogCacheStoreRejectsNonPositiveFetchedTimes(t *testing.T) { | |||
| 181 | + | t.Parallel() | |||
| 182 | + | ||||
| 183 | + | for _, tc := range []struct { | |||
| 184 | + | name string | |||
| 185 | + | fetchedAt time.Time | |||
| 186 | + | }{ | |||
| 187 | + | {name: "zero Unix nano", fetchedAt: time.Unix(0, 0)}, | |||
| 188 | + | {name: "negative Unix nano", fetchedAt: time.Unix(0, -1)}, | |||
| 189 | + | } { | |||
| 190 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 191 | + | t.Parallel() | |||
| 192 | + | path := filepath.Join(t.TempDir(), "remote-catalog-cache.json") | |||
| 193 | + | entries := []ports.RemoteCatalogCacheEntry{{ | |||
| 194 | + | Host: "arch", | |||
| 195 | + | FetchedAt: tc.fetchedAt, | |||
| 196 | + | Sessions: []ports.RemoteCatalogSession{}, | |||
| 197 | + | }} | |||
| 198 | + | ||||
| 199 | + | err := NewFileCatalogCache(path).Store(entries) | |||
| 200 | + | ||||
| 201 | + | if err == nil { | |||
| 202 | + | t.Fatal("Store() error = nil, want non-positive fetched time error") | |||
| 203 | + | } | |||
| 204 | + | if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { | |||
| 205 | + | t.Fatalf("failed Store created cache: stat error = %v", statErr) | |||
| 206 | + | } | |||
| 207 | + | }) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func TestCatalogCacheStoreHardensExistingFilePermissions(t *testing.T) { | |||
| 212 | + | t.Parallel() | |||
| 213 | + | ||||
| 214 | + | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |||
| 215 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |||
| 216 | + | t.Fatalf("mkdir: %v", err) | |||
| 217 | + | } | |||
| 218 | + | if err := os.WriteFile(path, []byte(`{"version":1,"hosts":[]}`), 0o644); err != nil { | |||
| 219 | + | t.Fatalf("seed cache: %v", err) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | cache := NewFileCatalogCache(path) | |||
| 223 | + | if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err != nil { | |||
| 224 | + | t.Fatalf("Store() error = %v", err) | |||
| 225 | + | } | |||
| 226 | + | info, err := os.Stat(path) | |||
| 227 | + | if err != nil { | |||
| 228 | + | t.Fatalf("stat cache: %v", err) | |||
| 229 | + | } | |||
| 230 | + | if perm := info.Mode().Perm(); perm != 0o600 { | |||
| 231 | + | t.Fatalf("cache permissions = %04o, want 0600", perm) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) { | |||
| 236 | + | t.Parallel() | |||
| 237 | + | ||||
| 238 | + | dir := filepath.Join(t.TempDir(), "vev") | |||
| 239 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 240 | + | t.Fatalf("mkdir parent: %v", err) | |||
| 241 | + | } | |||
| 242 | + | path := filepath.Join(dir, "remote-catalog-cache.json") | |||
| 243 | + | if err := os.Mkdir(path, 0o700); err != nil { | |||
| 244 | + | t.Fatalf("mkdir destination: %v", err) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | cache := NewFileCatalogCache(path) | |||
| 248 | + | if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err == nil { | |||
| 249 | + | t.Fatal("Store() error = nil, want rename error") | |||
| 250 | + | } | |||
| 251 | + | entries, err := os.ReadDir(dir) | |||
| 252 | + | if err != nil { | |||
| 253 | + | t.Fatalf("read cache directory: %v", err) | |||
| 254 | + | } | |||
| 255 | + | for _, entry := range entries { | |||
| 256 | + | if strings.Contains(entry.Name(), ".tmp") { | |||
| 257 | + | t.Fatalf("temporary file left behind after failed rename: %q", entry.Name()) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool { | |||
| 263 | + | return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && slices.Equal(a.Sessions, b.Sessions) | |||
| 264 | + | } | |||
M internal/adapters/remote/host_registry.go +16 -5
| @@ -17,7 +17,13 @@ import ( | |||
| 17 | 17 | "github.com/bnema/vev/pkg/safedir" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | - | const hostsFileVersion = 2 | |
| 20 | + | const ( | |
| 21 | + | hostsFileName = "hosts.json" | |
| 22 | + | hostsFileVersion = 2 | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // HostStorePath returns the canonical location of the remote host store in stateDir. | |
| 26 | + | func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) } | |
| 21 | 27 | ||
| 22 | 28 | type hostsFile struct { | |
| 23 | 29 | Version int `json:"version"` | |
| @@ -273,10 +279,15 @@ func (s *fileHostStore) saveLocked(state hostState) error { | |||
| 273 | 279 | if err != nil { | |
| 274 | 280 | return err | |
| 275 | 281 | } | |
| 276 | - | payload = append(payload, '\n') | |
| 282 | + | return atomicReplacePrivateFile(s.path, append(payload, '\n')) | |
| 283 | + | } | |
| 277 | 284 | ||
| 278 | - | dir := filepath.Dir(s.path) | |
| 279 | - | tmp, err := os.CreateTemp(dir, ".hosts-*.tmp") | |
| 285 | + | func atomicReplacePrivateFile(path string, payload []byte) error { | |
| 286 | + | dir := filepath.Dir(path) | |
| 287 | + | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 288 | + | return err | |
| 289 | + | } | |
| 290 | + | tmp, err := os.CreateTemp(dir, ".vev-*.tmp") | |
| 280 | 291 | if err != nil { | |
| 281 | 292 | return err | |
| 282 | 293 | } | |
| @@ -300,7 +311,7 @@ func (s *fileHostStore) saveLocked(state hostState) error { | |||
| 300 | 311 | if err := tmp.Close(); err != nil { | |
| 301 | 312 | return err | |
| 302 | 313 | } | |
| 303 | - | if err := os.Rename(tmpName, s.path); err != nil { | |
| 314 | + | if err := os.Rename(tmpName, path); err != nil { | |
| 304 | 315 | return err | |
| 305 | 316 | } | |
| 306 | 317 | committed = true | |
| @@ -17,7 +17,13 @@ import ( | |||||
| 17 | "github.com/bnema/vev/pkg/safedir" | 17 | "github.com/bnema/vev/pkg/safedir" | ||
| 18 | ) | 18 | ) | ||
| 19 | 19 | ||||
| 20 | - | const hostsFileVersion = 2 | 20 | + | const ( |
| 21 | + | hostsFileName = "hosts.json" | |||
| 22 | + | hostsFileVersion = 2 | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // HostStorePath returns the canonical location of the remote host store in stateDir. | |||
| 26 | + | func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) } | |||
| 21 | 27 | ||||
| 22 | type hostsFile struct { | 28 | type hostsFile struct { | ||
| 23 | Version int `json:"version"` | 29 | Version int `json:"version"` | ||
| @@ -273,10 +279,15 @@ func (s *fileHostStore) saveLocked(state hostState) error { | |||||
| 273 | if err != nil { | 279 | if err != nil { | ||
| 274 | return err | 280 | return err | ||
| 275 | } | 281 | } | ||
| 276 | - | payload = append(payload, '\n') | 282 | + | return atomicReplacePrivateFile(s.path, append(payload, '\n')) |
| 283 | + | } | |||
| 277 | 284 | ||||
| 278 | - | dir := filepath.Dir(s.path) | 285 | + | func atomicReplacePrivateFile(path string, payload []byte) error { |
| 279 | - | tmp, err := os.CreateTemp(dir, ".hosts-*.tmp") | 286 | + | dir := filepath.Dir(path) |
| 287 | + | if err := safedir.EnsurePrivate(dir); err != nil { | |||
| 288 | + | return err | |||
| 289 | + | } | |||
| 290 | + | tmp, err := os.CreateTemp(dir, ".vev-*.tmp") | |||
| 280 | if err != nil { | 291 | if err != nil { | ||
| 281 | return err | 292 | return err | ||
| 282 | } | 293 | } | ||
| @@ -300,7 +311,7 @@ func (s *fileHostStore) saveLocked(state hostState) error { | |||||
| 300 | if err := tmp.Close(); err != nil { | 311 | if err := tmp.Close(); err != nil { | ||
| 301 | return err | 312 | return err | ||
| 302 | } | 313 | } | ||
| 303 | - | if err := os.Rename(tmpName, s.path); err != nil { | 314 | + | if err := os.Rename(tmpName, path); err != nil { |
| 304 | return err | 315 | return err | ||
| 305 | } | 316 | } | ||
| 306 | committed = true | 317 | committed = true | ||
M internal/adapters/remote/host_registry_test.go +19 -1
| @@ -13,6 +13,14 @@ import ( | |||
| 13 | 13 | "testing" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | func TestHostStorePath(t *testing.T) { | |
| 17 | + | stateDir := filepath.Join(t.TempDir(), "state") | |
| 18 | + | want := filepath.Join(stateDir, "hosts.json") | |
| 19 | + | if got := HostStorePath(stateDir); got != want { | |
| 20 | + | t.Fatalf("HostStorePath(%q) = %q, want %q", stateDir, got, want) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 16 | 24 | func TestHostStoreCrossProcessHelper(t *testing.T) { | |
| 17 | 25 | if os.Getenv("VEV_HOST_STORE_HELPER") != "1" { | |
| 18 | 26 | t.Skip("helper process") | |
| @@ -515,13 +523,16 @@ func TestHostStore(t *testing.T) { | |||
| 515 | 523 | } | |
| 516 | 524 | }) | |
| 517 | 525 | ||
| 518 | - | t.Run("hardens existing lock file permissions to 0600", func(t *testing.T) { | |
| 526 | + | t.Run("hardens existing file and lock permissions to 0600", func(t *testing.T) { | |
| 519 | 527 | t.Parallel() | |
| 520 | 528 | path := storePath(t) | |
| 521 | 529 | dir := filepath.Dir(path) | |
| 522 | 530 | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 523 | 531 | t.Fatalf("mkdir: %v", err) | |
| 524 | 532 | } | |
| 533 | + | if err := os.WriteFile(path, []byte(`{"version":2,"pinned":[],"learned":[]}`), 0o644); err != nil { | |
| 534 | + | t.Fatalf("seed hosts: %v", err) | |
| 535 | + | } | |
| 525 | 536 | lockPath := path + ".lock" | |
| 526 | 537 | if err := os.WriteFile(lockPath, nil, 0o644); err != nil { | |
| 527 | 538 | t.Fatalf("seed lock: %v", err) | |
| @@ -539,6 +550,13 @@ func TestHostStore(t *testing.T) { | |||
| 539 | 550 | t.Fatalf("Remember() error = %v", err) | |
| 540 | 551 | } | |
| 541 | 552 | ||
| 553 | + | info, err = os.Stat(path) | |
| 554 | + | if err != nil { | |
| 555 | + | t.Fatalf("stat hosts: %v", err) | |
| 556 | + | } | |
| 557 | + | if perm := info.Mode().Perm(); perm != 0o600 { | |
| 558 | + | t.Fatalf("hosts permissions = %04o, want 0600", perm) | |
| 559 | + | } | |
| 542 | 560 | info, err = os.Stat(lockPath) | |
| 543 | 561 | if err != nil { | |
| 544 | 562 | t.Fatalf("stat lock: %v", err) | |
| @@ -13,6 +13,14 @@ import ( | |||||
| 13 | "testing" | 13 | "testing" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | func TestHostStorePath(t *testing.T) { | |||
| 17 | + | stateDir := filepath.Join(t.TempDir(), "state") | |||
| 18 | + | want := filepath.Join(stateDir, "hosts.json") | |||
| 19 | + | if got := HostStorePath(stateDir); got != want { | |||
| 20 | + | t.Fatalf("HostStorePath(%q) = %q, want %q", stateDir, got, want) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 16 | func TestHostStoreCrossProcessHelper(t *testing.T) { | 24 | func TestHostStoreCrossProcessHelper(t *testing.T) { | ||
| 17 | if os.Getenv("VEV_HOST_STORE_HELPER") != "1" { | 25 | if os.Getenv("VEV_HOST_STORE_HELPER") != "1" { | ||
| 18 | t.Skip("helper process") | 26 | t.Skip("helper process") | ||
| @@ -515,13 +523,16 @@ func TestHostStore(t *testing.T) { | |||||
| 515 | } | 523 | } | ||
| 516 | }) | 524 | }) | ||
| 517 | 525 | ||||
| 518 | - | t.Run("hardens existing lock file permissions to 0600", func(t *testing.T) { | 526 | + | t.Run("hardens existing file and lock permissions to 0600", func(t *testing.T) { |
| 519 | t.Parallel() | 527 | t.Parallel() | ||
| 520 | path := storePath(t) | 528 | path := storePath(t) | ||
| 521 | dir := filepath.Dir(path) | 529 | dir := filepath.Dir(path) | ||
| 522 | if err := os.MkdirAll(dir, 0o700); err != nil { | 530 | if err := os.MkdirAll(dir, 0o700); err != nil { | ||
| 523 | t.Fatalf("mkdir: %v", err) | 531 | t.Fatalf("mkdir: %v", err) | ||
| 524 | } | 532 | } | ||
| 533 | + | if err := os.WriteFile(path, []byte(`{"version":2,"pinned":[],"learned":[]}`), 0o644); err != nil { | |||
| 534 | + | t.Fatalf("seed hosts: %v", err) | |||
| 535 | + | } | |||
| 525 | lockPath := path + ".lock" | 536 | lockPath := path + ".lock" | ||
| 526 | if err := os.WriteFile(lockPath, nil, 0o644); err != nil { | 537 | if err := os.WriteFile(lockPath, nil, 0o644); err != nil { | ||
| 527 | t.Fatalf("seed lock: %v", err) | 538 | t.Fatalf("seed lock: %v", err) | ||
| @@ -539,6 +550,13 @@ func TestHostStore(t *testing.T) { | |||||
| 539 | t.Fatalf("Remember() error = %v", err) | 550 | t.Fatalf("Remember() error = %v", err) | ||
| 540 | } | 551 | } | ||
| 541 | 552 | ||||
| 553 | + | info, err = os.Stat(path) | |||
| 554 | + | if err != nil { | |||
| 555 | + | t.Fatalf("stat hosts: %v", err) | |||
| 556 | + | } | |||
| 557 | + | if perm := info.Mode().Perm(); perm != 0o600 { | |||
| 558 | + | t.Fatalf("hosts permissions = %04o, want 0600", perm) | |||
| 559 | + | } | |||
| 542 | info, err = os.Stat(lockPath) | 560 | info, err = os.Stat(lockPath) | ||
| 543 | if err != nil { | 561 | if err != nil { | ||
| 544 | t.Fatalf("stat lock: %v", err) | 562 | t.Fatalf("stat lock: %v", err) | ||
M internal/app/remote_hosts.go +2 -3
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | 9 | "os" | |
| 10 | - | "path/filepath" | |
| 11 | 10 | "sort" | |
| 12 | 11 | "text/tabwriter" | |
| 13 | 12 | ||
| @@ -46,7 +45,7 @@ func (d remoteHostDeps) withDefaults() remoteHostDeps { | |||
| 46 | 45 | d.stateDir = platform.StateDir | |
| 47 | 46 | } | |
| 48 | 47 | if d.store == nil { | |
| 49 | - | d.store = remoteadapter.NewFileHostStore(filepath.Join(d.stateDir(), "hosts.json")) | |
| 48 | + | d.store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(d.stateDir())) | |
| 50 | 49 | } | |
| 51 | 50 | if d.catalog == nil { | |
| 52 | 51 | d.catalog = remoteadapter.NewCatalogClient() | |
| @@ -277,7 +276,7 @@ func attachRememberLearner(deps runAttachDeps, remoteTarget string, _ *slog.Logg | |||
| 277 | 276 | if stateDir == nil { | |
| 278 | 277 | stateDir = platform.StateDir | |
| 279 | 278 | } | |
| 280 | - | store = remoteadapter.NewFileHostStore(filepath.Join(stateDir(), "hosts.json")) | |
| 279 | + | store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(stateDir())) | |
| 281 | 280 | } | |
| 282 | 281 | return remoteHostLearner{store: store, target: remoteTarget} | |
| 283 | 282 | } | |
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "io" | 7 | "io" | ||
| 8 | "log/slog" | 8 | "log/slog" | ||
| 9 | "os" | 9 | "os" | ||
| 10 | - | "path/filepath" | |||
| 11 | "sort" | 10 | "sort" | ||
| 12 | "text/tabwriter" | 11 | "text/tabwriter" | ||
| 13 | 12 | ||||
| @@ -46,7 +45,7 @@ func (d remoteHostDeps) withDefaults() remoteHostDeps { | |||||
| 46 | d.stateDir = platform.StateDir | 45 | d.stateDir = platform.StateDir | ||
| 47 | } | 46 | } | ||
| 48 | if d.store == nil { | 47 | if d.store == nil { | ||
| 49 | - | d.store = remoteadapter.NewFileHostStore(filepath.Join(d.stateDir(), "hosts.json")) | 48 | + | d.store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(d.stateDir())) |
| 50 | } | 49 | } | ||
| 51 | if d.catalog == nil { | 50 | if d.catalog == nil { | ||
| 52 | d.catalog = remoteadapter.NewCatalogClient() | 51 | d.catalog = remoteadapter.NewCatalogClient() | ||
| @@ -277,7 +276,7 @@ func attachRememberLearner(deps runAttachDeps, remoteTarget string, _ *slog.Logg | |||||
| 277 | if stateDir == nil { | 276 | if stateDir == nil { | ||
| 278 | stateDir = platform.StateDir | 277 | stateDir = platform.StateDir | ||
| 279 | } | 278 | } | ||
| 280 | - | store = remoteadapter.NewFileHostStore(filepath.Join(stateDir(), "hosts.json")) | 279 | + | store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(stateDir())) |
| 281 | } | 280 | } | ||
| 282 | return remoteHostLearner{store: store, target: remoteTarget} | 281 | return remoteHostLearner{store: store, target: remoteTarget} | ||
| 283 | } | 282 | } | ||
M internal/app/run.go +24 -1
| @@ -343,6 +343,9 @@ func parseHostArgs(args []string) (command, error) { | |||
| 343 | 343 | // opening real transports. | |
| 344 | 344 | var ( | |
| 345 | 345 | newPerformanceTrace = performanceTrace | |
| 346 | + | newRemoteHostStore = remoteadapter.NewFileHostStore | |
| 347 | + | newRemoteCatalogCache = remoteadapter.NewFileCatalogCache | |
| 348 | + | newRemoteCatalogClient = remoteadapter.NewCatalogClient | |
| 346 | 349 | newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | |
| 347 | 350 | return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer) | |
| 348 | 351 | } | |
| @@ -610,6 +613,10 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||
| 610 | 613 | if observerCloser != nil { | |
| 611 | 614 | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |
| 612 | 615 | } | |
| 616 | + | remoteDiscoveryOpt, err := remoteDiscoveryDaemonOption(platform.StateDir(), observer, os.Getenv(envRemoteTransport)) | |
| 617 | + | if err != nil { | |
| 618 | + | return err | |
| 619 | + | } | |
| 613 | 620 | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | |
| 614 | 621 | if !pprofAddrIsLoopback(addr) { | |
| 615 | 622 | log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr) | |
| @@ -622,7 +629,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||
| 622 | 629 | log.Info("pprof enabled", "addr", addr) | |
| 623 | 630 | } | |
| 624 | 631 | ||
| 625 | - | daemonOpts := []daemon.Option(nil) | |
| 632 | + | daemonOpts := []daemon.Option{remoteDiscoveryOpt} | |
| 626 | 633 | if observer != nil { | |
| 627 | 634 | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | |
| 628 | 635 | } | |
| @@ -781,6 +788,22 @@ func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) | |||
| 781 | 788 | } | |
| 782 | 789 | } | |
| 783 | 790 | ||
| 791 | + | // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from | |
| 792 | + | // the same validated transport selection used by direct remote attach. | |
| 793 | + | func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRuntimeObserver, transport string) (daemon.Option, error) { | |
| 794 | + | mode, err := remoteTransportModeFromEnv(transport) | |
| 795 | + | if err != nil { | |
| 796 | + | return nil, err | |
| 797 | + | } | |
| 798 | + | return daemon.WithRemoteDiscovery( | |
| 799 | + | newRemoteHostStore(remoteadapter.HostStorePath(stateDir)), | |
| 800 | + | newRemoteCatalogClient(), | |
| 801 | + | newRemoteCatalogCache(filepath.Join(stateDir, "remote-catalog-cache.json")), | |
| 802 | + | newRemoteDialerFactoryWithRuntimeObserver(observer), | |
| 803 | + | mode, | |
| 804 | + | ), nil | |
| 805 | + | } | |
| 806 | + | ||
| 784 | 807 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | |
| 785 | 808 | if activeSession != "" { | |
| 786 | 809 | if remoteTarget == "" && intent == ports.IntentNew { | |
| @@ -343,6 +343,9 @@ func parseHostArgs(args []string) (command, error) { | |||||
| 343 | // opening real transports. | 343 | // opening real transports. | ||
| 344 | var ( | 344 | var ( | ||
| 345 | newPerformanceTrace = performanceTrace | 345 | newPerformanceTrace = performanceTrace | ||
| 346 | + | newRemoteHostStore = remoteadapter.NewFileHostStore | |||
| 347 | + | newRemoteCatalogCache = remoteadapter.NewFileCatalogCache | |||
| 348 | + | newRemoteCatalogClient = remoteadapter.NewCatalogClient | |||
| 346 | newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | 349 | newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | ||
| 347 | return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer) | 350 | return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer) | ||
| 348 | } | 351 | } | ||
| @@ -610,6 +613,10 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||||
| 610 | if observerCloser != nil { | 613 | if observerCloser != nil { | ||
| 611 | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | 614 | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | ||
| 612 | } | 615 | } | ||
| 616 | + | remoteDiscoveryOpt, err := remoteDiscoveryDaemonOption(platform.StateDir(), observer, os.Getenv(envRemoteTransport)) | |||
| 617 | + | if err != nil { | |||
| 618 | + | return err | |||
| 619 | + | } | |||
| 613 | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | 620 | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | ||
| 614 | if !pprofAddrIsLoopback(addr) { | 621 | if !pprofAddrIsLoopback(addr) { | ||
| 615 | log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr) | 622 | log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr) | ||
| @@ -622,7 +629,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||||
| 622 | log.Info("pprof enabled", "addr", addr) | 629 | log.Info("pprof enabled", "addr", addr) | ||
| 623 | } | 630 | } | ||
| 624 | 631 | ||||
| 625 | - | daemonOpts := []daemon.Option(nil) | 632 | + | daemonOpts := []daemon.Option{remoteDiscoveryOpt} |
| 626 | if observer != nil { | 633 | if observer != nil { | ||
| 627 | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | 634 | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | ||
| 628 | } | 635 | } | ||
| @@ -781,6 +788,22 @@ func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) | |||||
| 781 | } | 788 | } | ||
| 782 | } | 789 | } | ||
| 783 | 790 | ||||
| 791 | + | // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from | |||
| 792 | + | // the same validated transport selection used by direct remote attach. | |||
| 793 | + | func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRuntimeObserver, transport string) (daemon.Option, error) { | |||
| 794 | + | mode, err := remoteTransportModeFromEnv(transport) | |||
| 795 | + | if err != nil { | |||
| 796 | + | return nil, err | |||
| 797 | + | } | |||
| 798 | + | return daemon.WithRemoteDiscovery( | |||
| 799 | + | newRemoteHostStore(remoteadapter.HostStorePath(stateDir)), | |||
| 800 | + | newRemoteCatalogClient(), | |||
| 801 | + | newRemoteCatalogCache(filepath.Join(stateDir, "remote-catalog-cache.json")), | |||
| 802 | + | newRemoteDialerFactoryWithRuntimeObserver(observer), | |||
| 803 | + | mode, | |||
| 804 | + | ), nil | |||
| 805 | + | } | |||
| 806 | + | ||||
| 784 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | 807 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | ||
| 785 | if activeSession != "" { | 808 | if activeSession != "" { | ||
| 786 | if remoteTarget == "" && intent == ports.IntentNew { | 809 | if remoteTarget == "" && intent == ports.IntentNew { | ||
M internal/app/run_test.go +51 -0
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | "time" | |
| 18 | 18 | ||
| 19 | 19 | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 20 | + | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 20 | 21 | "github.com/bnema/vev/internal/domain" | |
| 21 | 22 | "github.com/bnema/vev/internal/persist" | |
| 22 | 23 | "github.com/bnema/vev/internal/ports" | |
| @@ -1359,3 +1360,53 @@ func TestPprofAddrIsLoopback(t *testing.T) { | |||
| 1359 | 1360 | }) | |
| 1360 | 1361 | } | |
| 1361 | 1362 | } | |
| 1363 | + | ||
| 1364 | + | type appCatalogCacheStub struct { | |
| 1365 | + | loads int | |
| 1366 | + | } | |
| 1367 | + | ||
| 1368 | + | func (s *appCatalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |
| 1369 | + | s.loads++ | |
| 1370 | + | return nil, nil | |
| 1371 | + | } | |
| 1372 | + | ||
| 1373 | + | func (s *appCatalogCacheStub) Store([]ports.RemoteCatalogCacheEntry) error { return nil } | |
| 1374 | + | ||
| 1375 | + | func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) { | |
| 1376 | + | oldHostStore := newRemoteHostStore | |
| 1377 | + | oldCatalogCache := newRemoteCatalogCache | |
| 1378 | + | oldCatalogClient := newRemoteCatalogClient | |
| 1379 | + | oldDialerFactory := newRemoteDialerFactoryWithRuntimeObserver | |
| 1380 | + | t.Cleanup(func() { | |
| 1381 | + | newRemoteHostStore = oldHostStore | |
| 1382 | + | newRemoteCatalogCache = oldCatalogCache | |
| 1383 | + | newRemoteCatalogClient = oldCatalogClient | |
| 1384 | + | newRemoteDialerFactoryWithRuntimeObserver = oldDialerFactory | |
| 1385 | + | }) | |
| 1386 | + | ||
| 1387 | + | stateDir := t.TempDir() | |
| 1388 | + | cache := &appCatalogCacheStub{} | |
| 1389 | + | var hostPath, cachePath string | |
| 1390 | + | newRemoteHostStore = func(path string) ports.RemoteHostStore { | |
| 1391 | + | hostPath = path | |
| 1392 | + | return nil | |
| 1393 | + | } | |
| 1394 | + | newRemoteCatalogCache = func(path string) ports.RemoteCatalogCache { | |
| 1395 | + | cachePath = path | |
| 1396 | + | return cache | |
| 1397 | + | } | |
| 1398 | + | newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil } | |
| 1399 | + | newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { return nil } | |
| 1400 | + | ||
| 1401 | + | option, err := remoteDiscoveryDaemonOption(stateDir, nil, "stdio") | |
| 1402 | + | require.NoError(t, err) | |
| 1403 | + | _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option) | |
| 1404 | + | require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath) | |
| 1405 | + | require.Equal(t, filepath.Join(stateDir, "remote-catalog-cache.json"), cachePath) | |
| 1406 | + | require.Equal(t, 1, cache.loads, "daemon startup must load the cache once") | |
| 1407 | + | } | |
| 1408 | + | ||
| 1409 | + | func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) { | |
| 1410 | + | _, err := remoteDiscoveryDaemonOption(t.TempDir(), nil, "serial") | |
| 1411 | + | require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`) | |
| 1412 | + | } | |
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | "time" | 17 | "time" | ||
| 18 | 18 | ||||
| 19 | "github.com/bnema/vev/internal/adapters/lifecycle" | 19 | "github.com/bnema/vev/internal/adapters/lifecycle" | ||
| 20 | + | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |||
| 20 | "github.com/bnema/vev/internal/domain" | 21 | "github.com/bnema/vev/internal/domain" | ||
| 21 | "github.com/bnema/vev/internal/persist" | 22 | "github.com/bnema/vev/internal/persist" | ||
| 22 | "github.com/bnema/vev/internal/ports" | 23 | "github.com/bnema/vev/internal/ports" | ||
| @@ -1359,3 +1360,53 @@ func TestPprofAddrIsLoopback(t *testing.T) { | |||||
| 1359 | }) | 1360 | }) | ||
| 1360 | } | 1361 | } | ||
| 1361 | } | 1362 | } | ||
| 1363 | + | ||||
| 1364 | + | type appCatalogCacheStub struct { | |||
| 1365 | + | loads int | |||
| 1366 | + | } | |||
| 1367 | + | ||||
| 1368 | + | func (s *appCatalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 1369 | + | s.loads++ | |||
| 1370 | + | return nil, nil | |||
| 1371 | + | } | |||
| 1372 | + | ||||
| 1373 | + | func (s *appCatalogCacheStub) Store([]ports.RemoteCatalogCacheEntry) error { return nil } | |||
| 1374 | + | ||||
| 1375 | + | func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) { | |||
| 1376 | + | oldHostStore := newRemoteHostStore | |||
| 1377 | + | oldCatalogCache := newRemoteCatalogCache | |||
| 1378 | + | oldCatalogClient := newRemoteCatalogClient | |||
| 1379 | + | oldDialerFactory := newRemoteDialerFactoryWithRuntimeObserver | |||
| 1380 | + | t.Cleanup(func() { | |||
| 1381 | + | newRemoteHostStore = oldHostStore | |||
| 1382 | + | newRemoteCatalogCache = oldCatalogCache | |||
| 1383 | + | newRemoteCatalogClient = oldCatalogClient | |||
| 1384 | + | newRemoteDialerFactoryWithRuntimeObserver = oldDialerFactory | |||
| 1385 | + | }) | |||
| 1386 | + | ||||
| 1387 | + | stateDir := t.TempDir() | |||
| 1388 | + | cache := &appCatalogCacheStub{} | |||
| 1389 | + | var hostPath, cachePath string | |||
| 1390 | + | newRemoteHostStore = func(path string) ports.RemoteHostStore { | |||
| 1391 | + | hostPath = path | |||
| 1392 | + | return nil | |||
| 1393 | + | } | |||
| 1394 | + | newRemoteCatalogCache = func(path string) ports.RemoteCatalogCache { | |||
| 1395 | + | cachePath = path | |||
| 1396 | + | return cache | |||
| 1397 | + | } | |||
| 1398 | + | newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil } | |||
| 1399 | + | newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { return nil } | |||
| 1400 | + | ||||
| 1401 | + | option, err := remoteDiscoveryDaemonOption(stateDir, nil, "stdio") | |||
| 1402 | + | require.NoError(t, err) | |||
| 1403 | + | _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option) | |||
| 1404 | + | require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath) | |||
| 1405 | + | require.Equal(t, filepath.Join(stateDir, "remote-catalog-cache.json"), cachePath) | |||
| 1406 | + | require.Equal(t, 1, cache.loads, "daemon startup must load the cache once") | |||
| 1407 | + | } | |||
| 1408 | + | ||||
| 1409 | + | func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) { | |||
| 1410 | + | _, err := remoteDiscoveryDaemonOption(t.TempDir(), nil, "serial") | |||
| 1411 | + | require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`) | |||
| 1412 | + | } | |||
A internal/domain/remote_session.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "encoding/base64" | |
| 4 | + | ||
| 5 | + | // RemoteSessionKey identifies a discovered remote session independently from | |
| 6 | + | // its display label. | |
| 7 | + | type RemoteSessionKey struct { | |
| 8 | + | Host string | |
| 9 | + | Name string | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // Validate reports whether the host target and session name are both valid. | |
| 13 | + | func (k RemoteSessionKey) Validate() error { | |
| 14 | + | if err := ValidateRemoteHostTarget(k.Host); err != nil { | |
| 15 | + | return err | |
| 16 | + | } | |
| 17 | + | return ValidateSessionName(k.Name) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // ID returns the opaque, delimiter-safe ID for this remote session. Routing | |
| 21 | + | // retains the structured key; callers must not recover it by decoding this ID. | |
| 22 | + | func (k RemoteSessionKey) ID() SessionID { | |
| 23 | + | host := base64.RawURLEncoding.EncodeToString([]byte(k.Host)) | |
| 24 | + | name := base64.RawURLEncoding.EncodeToString([]byte(k.Name)) | |
| 25 | + | return SessionID("remote:" + host + "." + name) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Display returns the presentation label for this remote session. | |
| 29 | + | func (k RemoteSessionKey) Display() string { | |
| 30 | + | return k.Name + "@" + k.Host | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "encoding/base64" | |||
| 4 | + | ||||
| 5 | + | // RemoteSessionKey identifies a discovered remote session independently from | |||
| 6 | + | // its display label. | |||
| 7 | + | type RemoteSessionKey struct { | |||
| 8 | + | Host string | |||
| 9 | + | Name string | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // Validate reports whether the host target and session name are both valid. | |||
| 13 | + | func (k RemoteSessionKey) Validate() error { | |||
| 14 | + | if err := ValidateRemoteHostTarget(k.Host); err != nil { | |||
| 15 | + | return err | |||
| 16 | + | } | |||
| 17 | + | return ValidateSessionName(k.Name) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // ID returns the opaque, delimiter-safe ID for this remote session. Routing | |||
| 21 | + | // retains the structured key; callers must not recover it by decoding this ID. | |||
| 22 | + | func (k RemoteSessionKey) ID() SessionID { | |||
| 23 | + | host := base64.RawURLEncoding.EncodeToString([]byte(k.Host)) | |||
| 24 | + | name := base64.RawURLEncoding.EncodeToString([]byte(k.Name)) | |||
| 25 | + | return SessionID("remote:" + host + "." + name) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Display returns the presentation label for this remote session. | |||
| 29 | + | func (k RemoteSessionKey) Display() string { | |||
| 30 | + | return k.Name + "@" + k.Host | |||
| 31 | + | } | |||
A internal/domain/remote_session_test.go +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestRemoteSessionKeyID(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | key RemoteSessionKey | |
| 9 | + | want SessionID | |
| 10 | + | }{ | |
| 11 | + | { | |
| 12 | + | name: "uses raw URL base64 components", | |
| 13 | + | key: RemoteSessionKey{Host: "arch", Name: "hello"}, | |
| 14 | + | want: "remote:YXJjaA.aGVsbG8", | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | name: "preserves host metacharacters without delimiters", | |
| 18 | + | key: RemoteSessionKey{Host: "build@mule:2222/path.[x]", Name: "work.1"}, | |
| 19 | + | want: "remote:YnVpbGRAbXVsZToyMjIyL3BhdGguW3hd.d29yay4x", | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | name: "supports bracketed IPv6 hosts", | |
| 23 | + | key: RemoteSessionKey{Host: "user@[2001:db8::1]:2222", Name: "work"}, | |
| 24 | + | want: "remote:dXNlckBbMjAwMTpkYjg6OjFdOjIyMjI.d29yaw", | |
| 25 | + | }, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | for _, tt := range tests { | |
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |
| 30 | + | if got := tt.key.ID(); got != tt.want { | |
| 31 | + | t.Fatalf("RemoteSessionKey.ID() = %q, want %q", got, tt.want) | |
| 32 | + | } | |
| 33 | + | if got := tt.key.ID(); got != tt.key.ID() { | |
| 34 | + | t.Fatalf("RemoteSessionKey.ID() is unstable: %q != %q", got, tt.key.ID()) | |
| 35 | + | } | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestRemoteSessionKeyIDAvoidsRawConcatenationCollisions(t *testing.T) { | |
| 41 | + | tests := []struct { | |
| 42 | + | name string | |
| 43 | + | first RemoteSessionKey | |
| 44 | + | last RemoteSessionKey | |
| 45 | + | }{ | |
| 46 | + | { | |
| 47 | + | name: "host component delimiter", | |
| 48 | + | first: RemoteSessionKey{Host: "arch.work", Name: "shell"}, | |
| 49 | + | last: RemoteSessionKey{Host: "arch", Name: "work.shell"}, | |
| 50 | + | }, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |
| 55 | + | if tt.first.Host+"."+tt.first.Name != tt.last.Host+"."+tt.last.Name { | |
| 56 | + | t.Fatal("test setup does not collide under raw concatenation") | |
| 57 | + | } | |
| 58 | + | if tt.first.ID() == tt.last.ID() { | |
| 59 | + | t.Fatalf("distinct keys have the same ID: %q", tt.first.ID()) | |
| 60 | + | } | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestRemoteSessionKeyValidate(t *testing.T) { | |
| 66 | + | tests := []struct { | |
| 67 | + | name string | |
| 68 | + | key RemoteSessionKey | |
| 69 | + | wantErr bool | |
| 70 | + | }{ | |
| 71 | + | {name: "valid", key: RemoteSessionKey{Host: "build@mule:2222/path", Name: "work.1"}}, | |
| 72 | + | {name: "empty host", key: RemoteSessionKey{Name: "work"}, wantErr: true}, | |
| 73 | + | {name: "host whitespace", key: RemoteSessionKey{Host: "build mule", Name: "work"}, wantErr: true}, | |
| 74 | + | {name: "empty session", key: RemoteSessionKey{Host: "arch"}, wantErr: true}, | |
| 75 | + | {name: "unsafe session", key: RemoteSessionKey{Host: "arch", Name: "work/session"}, wantErr: true}, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | for _, tt := range tests { | |
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |
| 80 | + | err := tt.key.Validate() | |
| 81 | + | if tt.wantErr && err == nil { | |
| 82 | + | t.Fatal("RemoteSessionKey.Validate() = nil, want error") | |
| 83 | + | } | |
| 84 | + | if !tt.wantErr && err != nil { | |
| 85 | + | t.Fatalf("RemoteSessionKey.Validate() = %v, want nil", err) | |
| 86 | + | } | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) { | |
| 92 | + | key := RemoteSessionKey{Host: "arch", Name: "hello"} | |
| 93 | + | ||
| 94 | + | if got, want := key.Display(), "hello@arch"; got != want { | |
| 95 | + | t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, want) | |
| 96 | + | } | |
| 97 | + | if key.Host != "arch" || key.Name != "hello" { | |
| 98 | + | t.Fatalf("Display mutated routing key: %#v", key) | |
| 99 | + | } | |
| 100 | + | } | |
| @@ -0,0 +1,100 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestRemoteSessionKeyID(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | key RemoteSessionKey | |||
| 9 | + | want SessionID | |||
| 10 | + | }{ | |||
| 11 | + | { | |||
| 12 | + | name: "uses raw URL base64 components", | |||
| 13 | + | key: RemoteSessionKey{Host: "arch", Name: "hello"}, | |||
| 14 | + | want: "remote:YXJjaA.aGVsbG8", | |||
| 15 | + | }, | |||
| 16 | + | { | |||
| 17 | + | name: "preserves host metacharacters without delimiters", | |||
| 18 | + | key: RemoteSessionKey{Host: "build@mule:2222/path.[x]", Name: "work.1"}, | |||
| 19 | + | want: "remote:YnVpbGRAbXVsZToyMjIyL3BhdGguW3hd.d29yay4x", | |||
| 20 | + | }, | |||
| 21 | + | { | |||
| 22 | + | name: "supports bracketed IPv6 hosts", | |||
| 23 | + | key: RemoteSessionKey{Host: "user@[2001:db8::1]:2222", Name: "work"}, | |||
| 24 | + | want: "remote:dXNlckBbMjAwMTpkYjg6OjFdOjIyMjI.d29yaw", | |||
| 25 | + | }, | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | for _, tt := range tests { | |||
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 30 | + | if got := tt.key.ID(); got != tt.want { | |||
| 31 | + | t.Fatalf("RemoteSessionKey.ID() = %q, want %q", got, tt.want) | |||
| 32 | + | } | |||
| 33 | + | if got := tt.key.ID(); got != tt.key.ID() { | |||
| 34 | + | t.Fatalf("RemoteSessionKey.ID() is unstable: %q != %q", got, tt.key.ID()) | |||
| 35 | + | } | |||
| 36 | + | }) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestRemoteSessionKeyIDAvoidsRawConcatenationCollisions(t *testing.T) { | |||
| 41 | + | tests := []struct { | |||
| 42 | + | name string | |||
| 43 | + | first RemoteSessionKey | |||
| 44 | + | last RemoteSessionKey | |||
| 45 | + | }{ | |||
| 46 | + | { | |||
| 47 | + | name: "host component delimiter", | |||
| 48 | + | first: RemoteSessionKey{Host: "arch.work", Name: "shell"}, | |||
| 49 | + | last: RemoteSessionKey{Host: "arch", Name: "work.shell"}, | |||
| 50 | + | }, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | + | if tt.first.Host+"."+tt.first.Name != tt.last.Host+"."+tt.last.Name { | |||
| 56 | + | t.Fatal("test setup does not collide under raw concatenation") | |||
| 57 | + | } | |||
| 58 | + | if tt.first.ID() == tt.last.ID() { | |||
| 59 | + | t.Fatalf("distinct keys have the same ID: %q", tt.first.ID()) | |||
| 60 | + | } | |||
| 61 | + | }) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestRemoteSessionKeyValidate(t *testing.T) { | |||
| 66 | + | tests := []struct { | |||
| 67 | + | name string | |||
| 68 | + | key RemoteSessionKey | |||
| 69 | + | wantErr bool | |||
| 70 | + | }{ | |||
| 71 | + | {name: "valid", key: RemoteSessionKey{Host: "build@mule:2222/path", Name: "work.1"}}, | |||
| 72 | + | {name: "empty host", key: RemoteSessionKey{Name: "work"}, wantErr: true}, | |||
| 73 | + | {name: "host whitespace", key: RemoteSessionKey{Host: "build mule", Name: "work"}, wantErr: true}, | |||
| 74 | + | {name: "empty session", key: RemoteSessionKey{Host: "arch"}, wantErr: true}, | |||
| 75 | + | {name: "unsafe session", key: RemoteSessionKey{Host: "arch", Name: "work/session"}, wantErr: true}, | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | for _, tt := range tests { | |||
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 80 | + | err := tt.key.Validate() | |||
| 81 | + | if tt.wantErr && err == nil { | |||
| 82 | + | t.Fatal("RemoteSessionKey.Validate() = nil, want error") | |||
| 83 | + | } | |||
| 84 | + | if !tt.wantErr && err != nil { | |||
| 85 | + | t.Fatalf("RemoteSessionKey.Validate() = %v, want nil", err) | |||
| 86 | + | } | |||
| 87 | + | }) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) { | |||
| 92 | + | key := RemoteSessionKey{Host: "arch", Name: "hello"} | |||
| 93 | + | ||||
| 94 | + | if got, want := key.Display(), "hello@arch"; got != want { | |||
| 95 | + | t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, want) | |||
| 96 | + | } | |||
| 97 | + | if key.Host != "arch" || key.Name != "hello" { | |||
| 98 | + | t.Fatalf("Display mutated routing key: %#v", key) | |||
| 99 | + | } | |||
| 100 | + | } | |||
M internal/ports/mocks/remote_mock.go +133 -0
| @@ -636,3 +636,136 @@ func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.C | |||
| 636 | 636 | _c.Call.Return(run) | |
| 637 | 637 | return _c | |
| 638 | 638 | } | |
| 639 | + | ||
| 640 | + | // NewMockRemoteCatalogCache creates a new instance of MockRemoteCatalogCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 641 | + | // The first argument is typically a *testing.T value. | |
| 642 | + | func NewMockRemoteCatalogCache(t interface { | |
| 643 | + | mock.TestingT | |
| 644 | + | Cleanup(func()) | |
| 645 | + | }) *MockRemoteCatalogCache { | |
| 646 | + | mock := &MockRemoteCatalogCache{} | |
| 647 | + | mock.Mock.Test(t) | |
| 648 | + | ||
| 649 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 650 | + | ||
| 651 | + | return mock | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | // MockRemoteCatalogCache is an autogenerated mock type for the RemoteCatalogCache type | |
| 655 | + | type MockRemoteCatalogCache struct { | |
| 656 | + | mock.Mock | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | type MockRemoteCatalogCache_Expecter struct { | |
| 660 | + | mock *mock.Mock | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | func (_m *MockRemoteCatalogCache) EXPECT() *MockRemoteCatalogCache_Expecter { | |
| 664 | + | return &MockRemoteCatalogCache_Expecter{mock: &_m.Mock} | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | // Load provides a mock function for the type MockRemoteCatalogCache | |
| 668 | + | func (_mock *MockRemoteCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |
| 669 | + | ret := _mock.Called() | |
| 670 | + | ||
| 671 | + | if len(ret) == 0 { | |
| 672 | + | panic("no return value specified for Load") | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | var r0 []ports.RemoteCatalogCacheEntry | |
| 676 | + | var r1 error | |
| 677 | + | if returnFunc, ok := ret.Get(0).(func() ([]ports.RemoteCatalogCacheEntry, error)); ok { | |
| 678 | + | return returnFunc() | |
| 679 | + | } | |
| 680 | + | if returnFunc, ok := ret.Get(0).(func() []ports.RemoteCatalogCacheEntry); ok { | |
| 681 | + | r0 = returnFunc() | |
| 682 | + | } else { | |
| 683 | + | if ret.Get(0) != nil { | |
| 684 | + | r0 = ret.Get(0).([]ports.RemoteCatalogCacheEntry) | |
| 685 | + | } | |
| 686 | + | } | |
| 687 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 688 | + | r1 = returnFunc() | |
| 689 | + | } else { | |
| 690 | + | r1 = ret.Error(1) | |
| 691 | + | } | |
| 692 | + | return r0, r1 | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | // MockRemoteCatalogCache_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' | |
| 696 | + | type MockRemoteCatalogCache_Load_Call struct { | |
| 697 | + | *mock.Call | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | // Load is a helper method to define mock.On call | |
| 701 | + | func (_e *MockRemoteCatalogCache_Expecter) Load() *MockRemoteCatalogCache_Load_Call { | |
| 702 | + | return &MockRemoteCatalogCache_Load_Call{Call: _e.mock.On("Load")} | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | func (_c *MockRemoteCatalogCache_Load_Call) Run(run func()) *MockRemoteCatalogCache_Load_Call { | |
| 706 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 707 | + | run() | |
| 708 | + | }) | |
| 709 | + | return _c | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | func (_c *MockRemoteCatalogCache_Load_Call) Return(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry, err error) *MockRemoteCatalogCache_Load_Call { | |
| 713 | + | _c.Call.Return(remoteCatalogCacheEntrys, err) | |
| 714 | + | return _c | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | func (_c *MockRemoteCatalogCache_Load_Call) RunAndReturn(run func() ([]ports.RemoteCatalogCacheEntry, error)) *MockRemoteCatalogCache_Load_Call { | |
| 718 | + | _c.Call.Return(run) | |
| 719 | + | return _c | |
| 720 | + | } | |
| 721 | + | ||
| 722 | + | // Store provides a mock function for the type MockRemoteCatalogCache | |
| 723 | + | func (_mock *MockRemoteCatalogCache) Store(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry) error { | |
| 724 | + | ret := _mock.Called(remoteCatalogCacheEntrys) | |
| 725 | + | ||
| 726 | + | if len(ret) == 0 { | |
| 727 | + | panic("no return value specified for Store") | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | var r0 error | |
| 731 | + | if returnFunc, ok := ret.Get(0).(func([]ports.RemoteCatalogCacheEntry) error); ok { | |
| 732 | + | r0 = returnFunc(remoteCatalogCacheEntrys) | |
| 733 | + | } else { | |
| 734 | + | r0 = ret.Error(0) | |
| 735 | + | } | |
| 736 | + | return r0 | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | // MockRemoteCatalogCache_Store_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Store' | |
| 740 | + | type MockRemoteCatalogCache_Store_Call struct { | |
| 741 | + | *mock.Call | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | // Store is a helper method to define mock.On call | |
| 745 | + | // - remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry | |
| 746 | + | func (_e *MockRemoteCatalogCache_Expecter) Store(remoteCatalogCacheEntrys any) *MockRemoteCatalogCache_Store_Call { | |
| 747 | + | return &MockRemoteCatalogCache_Store_Call{Call: _e.mock.On("Store", remoteCatalogCacheEntrys)} | |
| 748 | + | } | |
| 749 | + | ||
| 750 | + | func (_c *MockRemoteCatalogCache_Store_Call) Run(run func(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry)) *MockRemoteCatalogCache_Store_Call { | |
| 751 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 752 | + | var arg0 []ports.RemoteCatalogCacheEntry | |
| 753 | + | if args[0] != nil { | |
| 754 | + | arg0 = args[0].([]ports.RemoteCatalogCacheEntry) | |
| 755 | + | } | |
| 756 | + | run( | |
| 757 | + | arg0, | |
| 758 | + | ) | |
| 759 | + | }) | |
| 760 | + | return _c | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | func (_c *MockRemoteCatalogCache_Store_Call) Return(err error) *MockRemoteCatalogCache_Store_Call { | |
| 764 | + | _c.Call.Return(err) | |
| 765 | + | return _c | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | func (_c *MockRemoteCatalogCache_Store_Call) RunAndReturn(run func(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry) error) *MockRemoteCatalogCache_Store_Call { | |
| 769 | + | _c.Call.Return(run) | |
| 770 | + | return _c | |
| 771 | + | } | |
| @@ -636,3 +636,136 @@ func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.C | |||||
| 636 | _c.Call.Return(run) | 636 | _c.Call.Return(run) | ||
| 637 | return _c | 637 | return _c | ||
| 638 | } | 638 | } | ||
| 639 | + | ||||
| 640 | + | // NewMockRemoteCatalogCache creates a new instance of MockRemoteCatalogCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 641 | + | // The first argument is typically a *testing.T value. | |||
| 642 | + | func NewMockRemoteCatalogCache(t interface { | |||
| 643 | + | mock.TestingT | |||
| 644 | + | Cleanup(func()) | |||
| 645 | + | }) *MockRemoteCatalogCache { | |||
| 646 | + | mock := &MockRemoteCatalogCache{} | |||
| 647 | + | mock.Mock.Test(t) | |||
| 648 | + | ||||
| 649 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 650 | + | ||||
| 651 | + | return mock | |||
| 652 | + | } | |||
| 653 | + | ||||
| 654 | + | // MockRemoteCatalogCache is an autogenerated mock type for the RemoteCatalogCache type | |||
| 655 | + | type MockRemoteCatalogCache struct { | |||
| 656 | + | mock.Mock | |||
| 657 | + | } | |||
| 658 | + | ||||
| 659 | + | type MockRemoteCatalogCache_Expecter struct { | |||
| 660 | + | mock *mock.Mock | |||
| 661 | + | } | |||
| 662 | + | ||||
| 663 | + | func (_m *MockRemoteCatalogCache) EXPECT() *MockRemoteCatalogCache_Expecter { | |||
| 664 | + | return &MockRemoteCatalogCache_Expecter{mock: &_m.Mock} | |||
| 665 | + | } | |||
| 666 | + | ||||
| 667 | + | // Load provides a mock function for the type MockRemoteCatalogCache | |||
| 668 | + | func (_mock *MockRemoteCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 669 | + | ret := _mock.Called() | |||
| 670 | + | ||||
| 671 | + | if len(ret) == 0 { | |||
| 672 | + | panic("no return value specified for Load") | |||
| 673 | + | } | |||
| 674 | + | ||||
| 675 | + | var r0 []ports.RemoteCatalogCacheEntry | |||
| 676 | + | var r1 error | |||
| 677 | + | if returnFunc, ok := ret.Get(0).(func() ([]ports.RemoteCatalogCacheEntry, error)); ok { | |||
| 678 | + | return returnFunc() | |||
| 679 | + | } | |||
| 680 | + | if returnFunc, ok := ret.Get(0).(func() []ports.RemoteCatalogCacheEntry); ok { | |||
| 681 | + | r0 = returnFunc() | |||
| 682 | + | } else { | |||
| 683 | + | if ret.Get(0) != nil { | |||
| 684 | + | r0 = ret.Get(0).([]ports.RemoteCatalogCacheEntry) | |||
| 685 | + | } | |||
| 686 | + | } | |||
| 687 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 688 | + | r1 = returnFunc() | |||
| 689 | + | } else { | |||
| 690 | + | r1 = ret.Error(1) | |||
| 691 | + | } | |||
| 692 | + | return r0, r1 | |||
| 693 | + | } | |||
| 694 | + | ||||
| 695 | + | // MockRemoteCatalogCache_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' | |||
| 696 | + | type MockRemoteCatalogCache_Load_Call struct { | |||
| 697 | + | *mock.Call | |||
| 698 | + | } | |||
| 699 | + | ||||
| 700 | + | // Load is a helper method to define mock.On call | |||
| 701 | + | func (_e *MockRemoteCatalogCache_Expecter) Load() *MockRemoteCatalogCache_Load_Call { | |||
| 702 | + | return &MockRemoteCatalogCache_Load_Call{Call: _e.mock.On("Load")} | |||
| 703 | + | } | |||
| 704 | + | ||||
| 705 | + | func (_c *MockRemoteCatalogCache_Load_Call) Run(run func()) *MockRemoteCatalogCache_Load_Call { | |||
| 706 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 707 | + | run() | |||
| 708 | + | }) | |||
| 709 | + | return _c | |||
| 710 | + | } | |||
| 711 | + | ||||
| 712 | + | func (_c *MockRemoteCatalogCache_Load_Call) Return(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry, err error) *MockRemoteCatalogCache_Load_Call { | |||
| 713 | + | _c.Call.Return(remoteCatalogCacheEntrys, err) | |||
| 714 | + | return _c | |||
| 715 | + | } | |||
| 716 | + | ||||
| 717 | + | func (_c *MockRemoteCatalogCache_Load_Call) RunAndReturn(run func() ([]ports.RemoteCatalogCacheEntry, error)) *MockRemoteCatalogCache_Load_Call { | |||
| 718 | + | _c.Call.Return(run) | |||
| 719 | + | return _c | |||
| 720 | + | } | |||
| 721 | + | ||||
| 722 | + | // Store provides a mock function for the type MockRemoteCatalogCache | |||
| 723 | + | func (_mock *MockRemoteCatalogCache) Store(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry) error { | |||
| 724 | + | ret := _mock.Called(remoteCatalogCacheEntrys) | |||
| 725 | + | ||||
| 726 | + | if len(ret) == 0 { | |||
| 727 | + | panic("no return value specified for Store") | |||
| 728 | + | } | |||
| 729 | + | ||||
| 730 | + | var r0 error | |||
| 731 | + | if returnFunc, ok := ret.Get(0).(func([]ports.RemoteCatalogCacheEntry) error); ok { | |||
| 732 | + | r0 = returnFunc(remoteCatalogCacheEntrys) | |||
| 733 | + | } else { | |||
| 734 | + | r0 = ret.Error(0) | |||
| 735 | + | } | |||
| 736 | + | return r0 | |||
| 737 | + | } | |||
| 738 | + | ||||
| 739 | + | // MockRemoteCatalogCache_Store_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Store' | |||
| 740 | + | type MockRemoteCatalogCache_Store_Call struct { | |||
| 741 | + | *mock.Call | |||
| 742 | + | } | |||
| 743 | + | ||||
| 744 | + | // Store is a helper method to define mock.On call | |||
| 745 | + | // - remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry | |||
| 746 | + | func (_e *MockRemoteCatalogCache_Expecter) Store(remoteCatalogCacheEntrys any) *MockRemoteCatalogCache_Store_Call { | |||
| 747 | + | return &MockRemoteCatalogCache_Store_Call{Call: _e.mock.On("Store", remoteCatalogCacheEntrys)} | |||
| 748 | + | } | |||
| 749 | + | ||||
| 750 | + | func (_c *MockRemoteCatalogCache_Store_Call) Run(run func(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry)) *MockRemoteCatalogCache_Store_Call { | |||
| 751 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 752 | + | var arg0 []ports.RemoteCatalogCacheEntry | |||
| 753 | + | if args[0] != nil { | |||
| 754 | + | arg0 = args[0].([]ports.RemoteCatalogCacheEntry) | |||
| 755 | + | } | |||
| 756 | + | run( | |||
| 757 | + | arg0, | |||
| 758 | + | ) | |||
| 759 | + | }) | |||
| 760 | + | return _c | |||
| 761 | + | } | |||
| 762 | + | ||||
| 763 | + | func (_c *MockRemoteCatalogCache_Store_Call) Return(err error) *MockRemoteCatalogCache_Store_Call { | |||
| 764 | + | _c.Call.Return(err) | |||
| 765 | + | return _c | |||
| 766 | + | } | |||
| 767 | + | ||||
| 768 | + | func (_c *MockRemoteCatalogCache_Store_Call) RunAndReturn(run func(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry) error) *MockRemoteCatalogCache_Store_Call { | |||
| 769 | + | _c.Call.Return(run) | |||
| 770 | + | return _c | |||
| 771 | + | } | |||
M internal/ports/remote.go +16 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "log/slog" | |
| 7 | + | "time" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // RemoteTransportMode selects the transport used for remote attach after CLI parsing. | |
| @@ -46,6 +47,21 @@ type RemoteCatalogClient interface { | |||
| 46 | 47 | List(ctx context.Context, target string) (RemoteCatalog, error) | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 50 | + | // RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for | |
| 51 | + | // immediate remote discovery at daemon startup. | |
| 52 | + | type RemoteCatalogCacheEntry struct { | |
| 53 | + | Host string | |
| 54 | + | FetchedAt time.Time | |
| 55 | + | Sessions []RemoteCatalogSession | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // RemoteCatalogCache persists complete remote discovery snapshots independently | |
| 59 | + | // from the remote host registry. | |
| 60 | + | type RemoteCatalogCache interface { | |
| 61 | + | Load() ([]RemoteCatalogCacheEntry, error) | |
| 62 | + | Store([]RemoteCatalogCacheEntry) error | |
| 63 | + | } | |
| 64 | + | ||
| 49 | 65 | // RemoteCatalogSession is one live session in the remote discovery catalog. | |
| 50 | 66 | // State is an explicit string contract (running|stopped|broken), not SessionState. | |
| 51 | 67 | type RemoteCatalogSession struct { | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "log/slog" | 6 | "log/slog" | ||
| 7 | + | "time" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | // RemoteTransportMode selects the transport used for remote attach after CLI parsing. | 10 | // RemoteTransportMode selects the transport used for remote attach after CLI parsing. | ||
| @@ -46,6 +47,21 @@ type RemoteCatalogClient interface { | |||||
| 46 | List(ctx context.Context, target string) (RemoteCatalog, error) | 47 | List(ctx context.Context, target string) (RemoteCatalog, error) | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 50 | + | // RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for | |||
| 51 | + | // immediate remote discovery at daemon startup. | |||
| 52 | + | type RemoteCatalogCacheEntry struct { | |||
| 53 | + | Host string | |||
| 54 | + | FetchedAt time.Time | |||
| 55 | + | Sessions []RemoteCatalogSession | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // RemoteCatalogCache persists complete remote discovery snapshots independently | |||
| 59 | + | // from the remote host registry. | |||
| 60 | + | type RemoteCatalogCache interface { | |||
| 61 | + | Load() ([]RemoteCatalogCacheEntry, error) | |||
| 62 | + | Store([]RemoteCatalogCacheEntry) error | |||
| 63 | + | } | |||
| 64 | + | ||||
| 49 | // RemoteCatalogSession is one live session in the remote discovery catalog. | 65 | // RemoteCatalogSession is one live session in the remote discovery catalog. | ||
| 50 | // State is an explicit string contract (running|stopped|broken), not SessionState. | 66 | // State is an explicit string contract (running|stopped|broken), not SessionState. | ||
| 51 | type RemoteCatalogSession struct { | 67 | type RemoteCatalogSession struct { | ||
M internal/usecase/daemon/daemon.go +22 -0
| @@ -296,6 +296,14 @@ type Daemon struct { | |||
| 296 | 296 | barScripts *barScriptState | |
| 297 | 297 | notices *noticeCenter | |
| 298 | 298 | resumeParkGrace time.Duration | |
| 299 | + | // remoteCatalog owns cache-derived discovery state independently of the | |
| 300 | + | // live attachment registry. Its cache reads and writes never hold d.mu. | |
| 301 | + | remoteCatalog remoteCatalogState | |
| 302 | + | remoteHostStore ports.RemoteHostStore | |
| 303 | + | remoteCatalogClient ports.RemoteCatalogClient | |
| 304 | + | remoteCatalogCache ports.RemoteCatalogCache | |
| 305 | + | remoteDialerFactory ports.RemoteDialerFactory | |
| 306 | + | remoteTransportMode ports.RemoteTransportMode | |
| 299 | 307 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 300 | 308 | // (see clipboard.go); empty means use os.TempDir(). | |
| 301 | 309 | tempDir string | |
| @@ -372,6 +380,18 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||
| 372 | 380 | return func(d *Daemon) { d.runtimeObserver = observer } | |
| 373 | 381 | } | |
| 374 | 382 | ||
| 383 | + | // WithRemoteDiscovery installs the remote discovery ports used by the daemon. | |
| 384 | + | // The composition root validates mode before constructing the daemon. | |
| 385 | + | func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalogClient, cache ports.RemoteCatalogCache, dialer ports.RemoteDialerFactory, mode ports.RemoteTransportMode) Option { | |
| 386 | + | return func(d *Daemon) { | |
| 387 | + | d.remoteHostStore = store | |
| 388 | + | d.remoteCatalogClient = catalog | |
| 389 | + | d.remoteCatalogCache = cache | |
| 390 | + | d.remoteDialerFactory = dialer | |
| 391 | + | d.remoteTransportMode = mode | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 375 | 395 | // WithShell overrides the command (and its args) each session spawns. The | |
| 376 | 396 | // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a | |
| 377 | 397 | // deterministic program. | |
| @@ -596,6 +616,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 596 | 616 | snapshotAdmitted: make(map[*snapshotCapture]struct{}), | |
| 597 | 617 | snapshotWake: make(chan struct{}, 1), | |
| 598 | 618 | notices: newNoticeCenter(), | |
| 619 | + | remoteCatalog: newRemoteCatalogState(), | |
| 599 | 620 | resumeParkGrace: defaultResumeParkGrace, | |
| 600 | 621 | barScripts: &barScriptState{ | |
| 601 | 622 | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| @@ -653,6 +674,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 653 | 674 | d.lastAllocatedCreatedAt = maxCreatedAt | |
| 654 | 675 | } | |
| 655 | 676 | d.mruSeq.Store(maxSeq) | |
| 677 | + | d.loadRemoteCatalogCache() | |
| 656 | 678 | return d | |
| 657 | 679 | } | |
| 658 | 680 | ||
| @@ -296,6 +296,14 @@ type Daemon struct { | |||||
| 296 | barScripts *barScriptState | 296 | barScripts *barScriptState | ||
| 297 | notices *noticeCenter | 297 | notices *noticeCenter | ||
| 298 | resumeParkGrace time.Duration | 298 | resumeParkGrace time.Duration | ||
| 299 | + | // remoteCatalog owns cache-derived discovery state independently of the | |||
| 300 | + | // live attachment registry. Its cache reads and writes never hold d.mu. | |||
| 301 | + | remoteCatalog remoteCatalogState | |||
| 302 | + | remoteHostStore ports.RemoteHostStore | |||
| 303 | + | remoteCatalogClient ports.RemoteCatalogClient | |||
| 304 | + | remoteCatalogCache ports.RemoteCatalogCache | |||
| 305 | + | remoteDialerFactory ports.RemoteDialerFactory | |||
| 306 | + | remoteTransportMode ports.RemoteTransportMode | |||
| 299 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 307 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| 300 | // (see clipboard.go); empty means use os.TempDir(). | 308 | // (see clipboard.go); empty means use os.TempDir(). | ||
| 301 | tempDir string | 309 | tempDir string | ||
| @@ -372,6 +380,18 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||||
| 372 | return func(d *Daemon) { d.runtimeObserver = observer } | 380 | return func(d *Daemon) { d.runtimeObserver = observer } | ||
| 373 | } | 381 | } | ||
| 374 | 382 | ||||
| 383 | + | // WithRemoteDiscovery installs the remote discovery ports used by the daemon. | |||
| 384 | + | // The composition root validates mode before constructing the daemon. | |||
| 385 | + | func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalogClient, cache ports.RemoteCatalogCache, dialer ports.RemoteDialerFactory, mode ports.RemoteTransportMode) Option { | |||
| 386 | + | return func(d *Daemon) { | |||
| 387 | + | d.remoteHostStore = store | |||
| 388 | + | d.remoteCatalogClient = catalog | |||
| 389 | + | d.remoteCatalogCache = cache | |||
| 390 | + | d.remoteDialerFactory = dialer | |||
| 391 | + | d.remoteTransportMode = mode | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 375 | // WithShell overrides the command (and its args) each session spawns. The | 395 | // WithShell overrides the command (and its args) each session spawns. The | ||
| 376 | // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a | 396 | // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a | ||
| 377 | // deterministic program. | 397 | // deterministic program. | ||
| @@ -596,6 +616,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 596 | snapshotAdmitted: make(map[*snapshotCapture]struct{}), | 616 | snapshotAdmitted: make(map[*snapshotCapture]struct{}), | ||
| 597 | snapshotWake: make(chan struct{}, 1), | 617 | snapshotWake: make(chan struct{}, 1), | ||
| 598 | notices: newNoticeCenter(), | 618 | notices: newNoticeCenter(), | ||
| 619 | + | remoteCatalog: newRemoteCatalogState(), | |||
| 599 | resumeParkGrace: defaultResumeParkGrace, | 620 | resumeParkGrace: defaultResumeParkGrace, | ||
| 600 | barScripts: &barScriptState{ | 621 | barScripts: &barScriptState{ | ||
| 601 | cfg: barConfigFromDomain(domain.Defaults().Bar), | 622 | cfg: barConfigFromDomain(domain.Defaults().Bar), | ||
| @@ -653,6 +674,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 653 | d.lastAllocatedCreatedAt = maxCreatedAt | 674 | d.lastAllocatedCreatedAt = maxCreatedAt | ||
| 654 | } | 675 | } | ||
| 655 | d.mruSeq.Store(maxSeq) | 676 | d.mruSeq.Store(maxSeq) | ||
| 677 | + | d.loadRemoteCatalogCache() | |||
| 656 | return d | 678 | return d | ||
| 657 | } | 679 | } | ||
| 658 | 680 | ||||
A internal/usecase/daemon/remote_catalog_state.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sort" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type remoteHostStatus uint8 | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | remoteHostCached remoteHostStatus = iota | |
| 15 | + | remoteHostRefreshing | |
| 16 | + | remoteHostFresh | |
| 17 | + | remoteHostUnreachable | |
| 18 | + | remoteHostVersionMismatch | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // remoteCatalogState owns the cache-derived discovery state. Its mutex never | |
| 22 | + | // covers cache or transport I/O; writeMu only serializes complete cache writes. | |
| 23 | + | type remoteCatalogState struct { | |
| 24 | + | mu sync.Mutex | |
| 25 | + | cache map[string]ports.RemoteCatalogCacheEntry | |
| 26 | + | status map[string]remoteHostStatus | |
| 27 | + | failure map[string]time.Time | |
| 28 | + | writeMu sync.Mutex | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func newRemoteCatalogState() remoteCatalogState { | |
| 32 | + | return remoteCatalogState{ | |
| 33 | + | cache: make(map[string]ports.RemoteCatalogCacheEntry), | |
| 34 | + | status: make(map[string]remoteHostStatus), | |
| 35 | + | failure: make(map[string]time.Time), | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry { | |
| 40 | + | return ports.RemoteCatalogCacheEntry{ | |
| 41 | + | Host: entry.Host, | |
| 42 | + | FetchedAt: entry.FetchedAt, | |
| 43 | + | Sessions: append([]ports.RemoteCatalogSession(nil), entry.Sessions...), | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (s *remoteCatalogState) replaceCache(entries []ports.RemoteCatalogCacheEntry) { | |
| 48 | + | if s == nil { | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | s.mu.Lock() | |
| 52 | + | defer s.mu.Unlock() | |
| 53 | + | s.cache = make(map[string]ports.RemoteCatalogCacheEntry, len(entries)) | |
| 54 | + | s.status = make(map[string]remoteHostStatus, len(entries)) | |
| 55 | + | s.failure = make(map[string]time.Time) | |
| 56 | + | for _, entry := range entries { | |
| 57 | + | s.cache[entry.Host] = cloneRemoteCatalogEntry(entry) | |
| 58 | + | s.status[entry.Host] = remoteHostCached | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (s *remoteCatalogState) cacheSnapshot() []ports.RemoteCatalogCacheEntry { | |
| 63 | + | if s == nil { | |
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | s.mu.Lock() | |
| 67 | + | defer s.mu.Unlock() | |
| 68 | + | entries := make([]ports.RemoteCatalogCacheEntry, 0, len(s.cache)) | |
| 69 | + | for _, entry := range s.cache { | |
| 70 | + | entries = append(entries, cloneRemoteCatalogEntry(entry)) | |
| 71 | + | } | |
| 72 | + | sort.Slice(entries, func(i, j int) bool { return entries[i].Host < entries[j].Host }) | |
| 73 | + | return entries | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (d *Daemon) loadRemoteCatalogCache() { | |
| 77 | + | if d == nil || d.remoteCatalogCache == nil { | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | entries, err := d.remoteCatalogCache.Load() | |
| 81 | + | if err != nil { | |
| 82 | + | d.log.Warn("loading remote catalog cache failed; starting empty", "err", err) | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | d.remoteCatalog.replaceCache(entries) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // persistRemoteCatalogCache writes the newest complete cache snapshot. Cache | |
| 89 | + | // I/O happens after releasing the state mutex and remains serialized with | |
| 90 | + | // other writes so an older snapshot cannot win a later atomic replacement. | |
| 91 | + | func (d *Daemon) persistRemoteCatalogCache() { | |
| 92 | + | if d == nil || d.remoteCatalogCache == nil { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | d.remoteCatalog.writeMu.Lock() | |
| 96 | + | defer d.remoteCatalog.writeMu.Unlock() | |
| 97 | + | ||
| 98 | + | entries := d.remoteCatalog.cacheSnapshot() | |
| 99 | + | if err := d.remoteCatalogCache.Store(entries); err != nil { | |
| 100 | + | d.log.Warn("storing remote catalog cache failed", "err", err) | |
| 101 | + | } | |
| 102 | + | } | |
| @@ -0,0 +1,102 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sort" | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type remoteHostStatus uint8 | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | remoteHostCached remoteHostStatus = iota | |||
| 15 | + | remoteHostRefreshing | |||
| 16 | + | remoteHostFresh | |||
| 17 | + | remoteHostUnreachable | |||
| 18 | + | remoteHostVersionMismatch | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // remoteCatalogState owns the cache-derived discovery state. Its mutex never | |||
| 22 | + | // covers cache or transport I/O; writeMu only serializes complete cache writes. | |||
| 23 | + | type remoteCatalogState struct { | |||
| 24 | + | mu sync.Mutex | |||
| 25 | + | cache map[string]ports.RemoteCatalogCacheEntry | |||
| 26 | + | status map[string]remoteHostStatus | |||
| 27 | + | failure map[string]time.Time | |||
| 28 | + | writeMu sync.Mutex | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func newRemoteCatalogState() remoteCatalogState { | |||
| 32 | + | return remoteCatalogState{ | |||
| 33 | + | cache: make(map[string]ports.RemoteCatalogCacheEntry), | |||
| 34 | + | status: make(map[string]remoteHostStatus), | |||
| 35 | + | failure: make(map[string]time.Time), | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry { | |||
| 40 | + | return ports.RemoteCatalogCacheEntry{ | |||
| 41 | + | Host: entry.Host, | |||
| 42 | + | FetchedAt: entry.FetchedAt, | |||
| 43 | + | Sessions: append([]ports.RemoteCatalogSession(nil), entry.Sessions...), | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (s *remoteCatalogState) replaceCache(entries []ports.RemoteCatalogCacheEntry) { | |||
| 48 | + | if s == nil { | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | s.mu.Lock() | |||
| 52 | + | defer s.mu.Unlock() | |||
| 53 | + | s.cache = make(map[string]ports.RemoteCatalogCacheEntry, len(entries)) | |||
| 54 | + | s.status = make(map[string]remoteHostStatus, len(entries)) | |||
| 55 | + | s.failure = make(map[string]time.Time) | |||
| 56 | + | for _, entry := range entries { | |||
| 57 | + | s.cache[entry.Host] = cloneRemoteCatalogEntry(entry) | |||
| 58 | + | s.status[entry.Host] = remoteHostCached | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (s *remoteCatalogState) cacheSnapshot() []ports.RemoteCatalogCacheEntry { | |||
| 63 | + | if s == nil { | |||
| 64 | + | return nil | |||
| 65 | + | } | |||
| 66 | + | s.mu.Lock() | |||
| 67 | + | defer s.mu.Unlock() | |||
| 68 | + | entries := make([]ports.RemoteCatalogCacheEntry, 0, len(s.cache)) | |||
| 69 | + | for _, entry := range s.cache { | |||
| 70 | + | entries = append(entries, cloneRemoteCatalogEntry(entry)) | |||
| 71 | + | } | |||
| 72 | + | sort.Slice(entries, func(i, j int) bool { return entries[i].Host < entries[j].Host }) | |||
| 73 | + | return entries | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (d *Daemon) loadRemoteCatalogCache() { | |||
| 77 | + | if d == nil || d.remoteCatalogCache == nil { | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | entries, err := d.remoteCatalogCache.Load() | |||
| 81 | + | if err != nil { | |||
| 82 | + | d.log.Warn("loading remote catalog cache failed; starting empty", "err", err) | |||
| 83 | + | return | |||
| 84 | + | } | |||
| 85 | + | d.remoteCatalog.replaceCache(entries) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // persistRemoteCatalogCache writes the newest complete cache snapshot. Cache | |||
| 89 | + | // I/O happens after releasing the state mutex and remains serialized with | |||
| 90 | + | // other writes so an older snapshot cannot win a later atomic replacement. | |||
| 91 | + | func (d *Daemon) persistRemoteCatalogCache() { | |||
| 92 | + | if d == nil || d.remoteCatalogCache == nil { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | d.remoteCatalog.writeMu.Lock() | |||
| 96 | + | defer d.remoteCatalog.writeMu.Unlock() | |||
| 97 | + | ||||
| 98 | + | entries := d.remoteCatalog.cacheSnapshot() | |||
| 99 | + | if err := d.remoteCatalogCache.Store(entries); err != nil { | |||
| 100 | + | d.log.Warn("storing remote catalog cache failed", "err", err) | |||
| 101 | + | } | |||
| 102 | + | } | |||
A internal/usecase/daemon/remote_catalog_state_test.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "io" | |
| 6 | + | "log/slog" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type catalogCacheStub struct { | |
| 16 | + | load func() ([]ports.RemoteCatalogCacheEntry, error) | |
| 17 | + | store func([]ports.RemoteCatalogCacheEntry) error | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (s catalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |
| 21 | + | if s.load == nil { | |
| 22 | + | return nil, nil | |
| 23 | + | } | |
| 24 | + | return s.load() | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (s catalogCacheStub) Store(entries []ports.RemoteCatalogCacheEntry) error { | |
| 28 | + | if s.store == nil { | |
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | return s.store(entries) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestRemoteCatalogCacheStartup(t *testing.T) { | |
| 35 | + | now := time.Unix(0, 42) | |
| 36 | + | tests := []struct { | |
| 37 | + | name string | |
| 38 | + | load func() ([]ports.RemoteCatalogCacheEntry, error) | |
| 39 | + | wantCache map[string]ports.RemoteCatalogCacheEntry | |
| 40 | + | wantStatus map[string]remoteHostStatus | |
| 41 | + | }{ | |
| 42 | + | { | |
| 43 | + | name: "loads cached entries", | |
| 44 | + | load: func() ([]ports.RemoteCatalogCacheEntry, error) { | |
| 45 | + | return []ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}}, nil | |
| 46 | + | }, | |
| 47 | + | wantCache: map[string]ports.RemoteCatalogCacheEntry{"arch": {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}}, | |
| 48 | + | wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached}, | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "invalid cache starts empty", | |
| 52 | + | load: func() ([]ports.RemoteCatalogCacheEntry, error) { | |
| 53 | + | return nil, errors.New("invalid cache") | |
| 54 | + | }, | |
| 55 | + | wantCache: map[string]ports.RemoteCatalogCacheEntry{}, | |
| 56 | + | wantStatus: map[string]remoteHostStatus{}, | |
| 57 | + | }, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, catalogCacheStub{load: tt.load}, nil, ports.RemoteTransportUDP)) | |
| 63 | + | ||
| 64 | + | d.remoteCatalog.mu.Lock() | |
| 65 | + | defer d.remoteCatalog.mu.Unlock() | |
| 66 | + | require.Equal(t, tt.wantCache, d.remoteCatalog.cache) | |
| 67 | + | require.Equal(t, tt.wantStatus, d.remoteCatalog.status) | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) { | |
| 73 | + | var d *Daemon | |
| 74 | + | entered := make(chan struct{}, 2) | |
| 75 | + | release := make(chan struct{}) | |
| 76 | + | var active, maxActive int | |
| 77 | + | var activeMu sync.Mutex | |
| 78 | + | ||
| 79 | + | cache := catalogCacheStub{store: func(entries []ports.RemoteCatalogCacheEntry) error { | |
| 80 | + | if !d.mu.TryLock() { | |
| 81 | + | return errors.New("daemon lock held during cache I/O") | |
| 82 | + | } | |
| 83 | + | d.mu.Unlock() | |
| 84 | + | if !d.remoteCatalog.mu.TryLock() { | |
| 85 | + | return errors.New("remote catalog lock held during cache I/O") | |
| 86 | + | } | |
| 87 | + | d.remoteCatalog.mu.Unlock() | |
| 88 | + | ||
| 89 | + | activeMu.Lock() | |
| 90 | + | active++ | |
| 91 | + | if active > maxActive { | |
| 92 | + | maxActive = active | |
| 93 | + | } | |
| 94 | + | activeMu.Unlock() | |
| 95 | + | entered <- struct{}{} | |
| 96 | + | <-release | |
| 97 | + | activeMu.Lock() | |
| 98 | + | active-- | |
| 99 | + | activeMu.Unlock() | |
| 100 | + | return nil | |
| 101 | + | }} | |
| 102 | + | d = New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, cache, nil, ports.RemoteTransportUDP)) | |
| 103 | + | d.remoteCatalog.mu.Lock() | |
| 104 | + | d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 1), Sessions: []ports.RemoteCatalogSession{{Name: "one", State: "running"}}} | |
| 105 | + | d.remoteCatalog.mu.Unlock() | |
| 106 | + | ||
| 107 | + | done := make(chan struct{}, 2) | |
| 108 | + | go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }() | |
| 109 | + | <-entered | |
| 110 | + | d.remoteCatalog.mu.Lock() | |
| 111 | + | d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 2), Sessions: []ports.RemoteCatalogSession{{Name: "two", State: "running"}}} | |
| 112 | + | d.remoteCatalog.mu.Unlock() | |
| 113 | + | go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }() | |
| 114 | + | ||
| 115 | + | select { | |
| 116 | + | case <-entered: | |
| 117 | + | t.Fatal("concurrent cache stores were not serialized") | |
| 118 | + | case <-time.After(10 * time.Millisecond): | |
| 119 | + | } | |
| 120 | + | close(release) | |
| 121 | + | <-entered | |
| 122 | + | <-done | |
| 123 | + | <-done | |
| 124 | + | ||
| 125 | + | activeMu.Lock() | |
| 126 | + | defer activeMu.Unlock() | |
| 127 | + | require.Equal(t, 1, maxActive) | |
| 128 | + | } | |
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "io" | |||
| 6 | + | "log/slog" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type catalogCacheStub struct { | |||
| 16 | + | load func() ([]ports.RemoteCatalogCacheEntry, error) | |||
| 17 | + | store func([]ports.RemoteCatalogCacheEntry) error | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (s catalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 21 | + | if s.load == nil { | |||
| 22 | + | return nil, nil | |||
| 23 | + | } | |||
| 24 | + | return s.load() | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (s catalogCacheStub) Store(entries []ports.RemoteCatalogCacheEntry) error { | |||
| 28 | + | if s.store == nil { | |||
| 29 | + | return nil | |||
| 30 | + | } | |||
| 31 | + | return s.store(entries) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestRemoteCatalogCacheStartup(t *testing.T) { | |||
| 35 | + | now := time.Unix(0, 42) | |||
| 36 | + | tests := []struct { | |||
| 37 | + | name string | |||
| 38 | + | load func() ([]ports.RemoteCatalogCacheEntry, error) | |||
| 39 | + | wantCache map[string]ports.RemoteCatalogCacheEntry | |||
| 40 | + | wantStatus map[string]remoteHostStatus | |||
| 41 | + | }{ | |||
| 42 | + | { | |||
| 43 | + | name: "loads cached entries", | |||
| 44 | + | load: func() ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 45 | + | return []ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}}, nil | |||
| 46 | + | }, | |||
| 47 | + | wantCache: map[string]ports.RemoteCatalogCacheEntry{"arch": {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}}, | |||
| 48 | + | wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached}, | |||
| 49 | + | }, | |||
| 50 | + | { | |||
| 51 | + | name: "invalid cache starts empty", | |||
| 52 | + | load: func() ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 53 | + | return nil, errors.New("invalid cache") | |||
| 54 | + | }, | |||
| 55 | + | wantCache: map[string]ports.RemoteCatalogCacheEntry{}, | |||
| 56 | + | wantStatus: map[string]remoteHostStatus{}, | |||
| 57 | + | }, | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, catalogCacheStub{load: tt.load}, nil, ports.RemoteTransportUDP)) | |||
| 63 | + | ||||
| 64 | + | d.remoteCatalog.mu.Lock() | |||
| 65 | + | defer d.remoteCatalog.mu.Unlock() | |||
| 66 | + | require.Equal(t, tt.wantCache, d.remoteCatalog.cache) | |||
| 67 | + | require.Equal(t, tt.wantStatus, d.remoteCatalog.status) | |||
| 68 | + | }) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) { | |||
| 73 | + | var d *Daemon | |||
| 74 | + | entered := make(chan struct{}, 2) | |||
| 75 | + | release := make(chan struct{}) | |||
| 76 | + | var active, maxActive int | |||
| 77 | + | var activeMu sync.Mutex | |||
| 78 | + | ||||
| 79 | + | cache := catalogCacheStub{store: func(entries []ports.RemoteCatalogCacheEntry) error { | |||
| 80 | + | if !d.mu.TryLock() { | |||
| 81 | + | return errors.New("daemon lock held during cache I/O") | |||
| 82 | + | } | |||
| 83 | + | d.mu.Unlock() | |||
| 84 | + | if !d.remoteCatalog.mu.TryLock() { | |||
| 85 | + | return errors.New("remote catalog lock held during cache I/O") | |||
| 86 | + | } | |||
| 87 | + | d.remoteCatalog.mu.Unlock() | |||
| 88 | + | ||||
| 89 | + | activeMu.Lock() | |||
| 90 | + | active++ | |||
| 91 | + | if active > maxActive { | |||
| 92 | + | maxActive = active | |||
| 93 | + | } | |||
| 94 | + | activeMu.Unlock() | |||
| 95 | + | entered <- struct{}{} | |||
| 96 | + | <-release | |||
| 97 | + | activeMu.Lock() | |||
| 98 | + | active-- | |||
| 99 | + | activeMu.Unlock() | |||
| 100 | + | return nil | |||
| 101 | + | }} | |||
| 102 | + | d = New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, cache, nil, ports.RemoteTransportUDP)) | |||
| 103 | + | d.remoteCatalog.mu.Lock() | |||
| 104 | + | d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 1), Sessions: []ports.RemoteCatalogSession{{Name: "one", State: "running"}}} | |||
| 105 | + | d.remoteCatalog.mu.Unlock() | |||
| 106 | + | ||||
| 107 | + | done := make(chan struct{}, 2) | |||
| 108 | + | go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }() | |||
| 109 | + | <-entered | |||
| 110 | + | d.remoteCatalog.mu.Lock() | |||
| 111 | + | d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 2), Sessions: []ports.RemoteCatalogSession{{Name: "two", State: "running"}}} | |||
| 112 | + | d.remoteCatalog.mu.Unlock() | |||
| 113 | + | go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }() | |||
| 114 | + | ||||
| 115 | + | select { | |||
| 116 | + | case <-entered: | |||
| 117 | + | t.Fatal("concurrent cache stores were not serialized") | |||
| 118 | + | case <-time.After(10 * time.Millisecond): | |||
| 119 | + | } | |||
| 120 | + | close(release) | |||
| 121 | + | <-entered | |||
| 122 | + | <-done | |||
| 123 | + | <-done | |||
| 124 | + | ||||
| 125 | + | activeMu.Lock() | |||
| 126 | + | defer activeMu.Unlock() | |||
| 127 | + | require.Equal(t, 1, maxActive) | |||
| 128 | + | } | |||