diff --git a/.cursor/rules/calf.mdc b/.cursor/rules/calf.mdc index 69785a5..ce70d78 100644 --- a/.cursor/rules/calf.mdc +++ b/.cursor/rules/calf.mdc @@ -68,6 +68,7 @@ calf/ │ │ │ ├── images.go Image list/inspect/remove endpoints │ │ │ ├── logs.go Log streaming endpoints (WebSocket) │ │ │ ├── volumes.go Volume CRUD + subresources +│ │ │ ├── networks.go Network list/inspect/remove endpoints │ │ │ ├── migrate.go Docker Desktop migration orchestration + status polling │ │ │ ├── registry.go Registry login/logout │ │ │ └── registry_login.go Docker Hub OAuth device-flow browser login @@ -80,6 +81,8 @@ calf/ │ │ │ ├── lima.go Lima runtime: manages the Lima VM, runs ops via `limactl shell ... nerdctl` │ │ │ ├── lima.yaml Embedded Lima VM template (go:embed) │ │ │ ├── nerdctl.go Shared nerdctl output parsing, compose project inference, log filtering +│ │ │ ├── network.go Network list/inspect/remove via nerdctl +│ │ │ ├── proxy.go HTTP/HTTPS proxy application in VM/native runtime │ │ │ ├── mock.go In-memory Runtime implementation used by backend tests │ │ │ ├── exec.go exec.CommandContext wrapper with transient-error retry │ │ │ ├── exec_attach.go PTY-based interactive exec attach (stdin/stdout/resize) @@ -122,6 +125,7 @@ calf/ │ │ │ ├── container_detail_screen.dart Tabs: logs/inspect/mounts/exec/files/stats (fl_chart, xterm) │ │ │ ├── compose_group_detail_screen.dart Mixed-color log view per compose project │ │ │ ├── resources_screen.dart Images/Volumes/Builds screens +│ │ │ ├── networks_screen.dart Network list and detail screens │ │ │ ├── volume_detail_screen.dart Stored-data / containers-in-use / exports tabs │ │ │ ├── volume_quick_export_screen.dart Quick export destination picker │ │ │ └── volume_schedule_export_screen.dart Schedule export configuration @@ -154,7 +158,7 @@ calf/ Entrypoint. Loads config, builds the logger, constructs the `runtime.Runtime` and `api.Server`, handles `SIGINT`/`SIGTERM` via `signal.NotifyContext`, manages a PID file at `~/.config/calf/calf.pid`, and has `ensurePort` logic that terminates a stale previous `calf` process holding the listen port before starting. The runtime starts asynchronously in a goroutine (failure is non-fatal at startup); shutdown stops both the HTTP server and the runtime with timeouts. ### `internal/config/` -- `config.go` — defines the `Config` struct (`listen_addr`, `log_level`, `vm_name`, `docker_socket`, `poll_interval_ms`, `cpus`, `memory_gb`, `memory_swap_gb`, `disk_gb`). Loads/saves as YAML at `~/.config/calf/config.yaml`, with defaults embedded via `//go:embed config.yaml`, a `withDefaults` backfill step, and `migrateLegacyDefaults` (rewrites the old `:8080` port to `:8765`). +- `config.go` — defines the `Config` struct (`listen_addr`, `log_level`, `vm_name`, `docker_socket`, `poll_interval_ms`, `cpus`, `memory_gb`, `memory_swap_gb`, `disk_gb`, `http_proxy`, `https_proxy`, `no_proxy`). Loads/saves as YAML at `~/.config/calf/config.yaml`, with defaults embedded via `//go:embed config.yaml`, a `withDefaults` backfill step, and `migrateLegacyDefaults` (rewrites the old `:8080` port to `:8765`). - `logger.go` — wraps `slog.NewTextHandler` with a level parser (`debug`/`warn`/`error`, default `info`). ### `internal/api/` @@ -169,7 +173,7 @@ HTTP server built on `net/http.ServeMux`. Every handler follows the same shape: - `runtime_ready.go` — blocks until the runtime is running (3-minute timeout); used before registry login. - `runtime_errors.go` — maps `ErrRuntimeNotRunning` to `503`. - `builds.go` — in-memory build history; `POST` triggers `RunBuild`. -- `containers.go` / `exec.go` / `images.go` / `logs.go` / `volumes.go` — CRUD plus subresources (logs, inspect, mounts, files, exec, stats). Exec/logs use WebSocket; other operations use one-shot HTTP. +- `containers.go` / `exec.go` / `images.go` / `logs.go` / `volumes.go` / `networks.go` — CRUD plus subresources (logs, inspect, mounts, files, exec, stats). Exec/logs use WebSocket; other operations use one-shot HTTP. - `migrate.go` — orchestrates the Docker Desktop migration in a background goroutine, exposes status polling. - `registry.go` / `registry_login.go` — basic registry login/logout plus Docker Hub OAuth device-flow browser login with session polling. @@ -189,7 +193,7 @@ Docker Hub OAuth2 device-code flow client. Polls for a token, decodes JWT claims ### `internal/runtime/` (core abstraction) - `runtime.go` — defines the `Runtime` interface (~30 methods: lifecycle, containers, images, volumes, builds, logs, exec, stats, registry) and shared JSON-tagged (snake_case) types (`Status`, `Container`, `Image`, `Volume`, `Build`, ...). `runtime.New(...)` selects `NewNative` on Linux, otherwise `NewLima`. - `native.go` — `Native` runtime: talks directly to a `nerdctl`/`docker.sock` already available on the Linux host, no VM involved. -- `lima.go` — `Lima` runtime: manages a Lima VM (embeds a `lima.yaml` template via `go:embed`), starts/creates/stops the instance via `limactl`, runs all container operations via `limactl shell -- sudo nerdctl ...`. Includes `localhostProxies` for macOS port-forwarding and conflict detection. +- `lima.go` — `Lima` runtime: manages a Lima VM (embeds a `lima.yaml` template via `go:embed`), starts/creates/stops the instance via `limactl`, runs all container operations via `limactl shell -- sudo nerdctl ...`. Includes `localhostProxies` for macOS port-forwarding and conflict detection, `host.docker.internal` via Lima `hostResolver`, sleep/wake proxy resync, and proxy application. - `nerdctl.go` — shared low-level helpers: JSON-line parsing of `nerdctl ps/images/volume ls/history` output, compose project/service inference, log-line noise filtering, log streaming plumbing. - `mock.go` — in-memory `Mock` implementation of the full `Runtime` interface, used by backend tests. - `exec.go` — generic `exec.CommandContext` wrapper with retry logic (`runCommandWithRetry`, retries only on `isTransientCommandError`). @@ -227,6 +231,7 @@ Simple JSON files under `~/.config/calf/ui/.json` (via `path_provider`'s a - `container_detail_screen.dart` — tabs for logs/inspect/mounts/exec/files/stats, using `fl_chart` and `xterm`. - `compose_group_detail_screen.dart` — mixed-color log view per compose project. - `resources_screen.dart` — Images/Volumes/Builds screens. +- `networks_screen.dart` — Network list (name + subnet) and detail (driver, scope, gateway, options). - `volume_detail_screen.dart` — stored-data / containers-in-use / exports tabs. - `volume_quick_export_screen.dart` — quick export destination picker (local file, image, registry). - `volume_schedule_export_screen.dart` — schedule export configuration (daily/weekly/monthly). @@ -274,6 +279,7 @@ CI (`.github/workflows/ci.yml`, both jobs on `macos-latest`): 9. **Commit messages:** conventional-commit style (`feat:`, `fix:`, `refactor:`, `chore:`), optionally scoped (`feat(runtime): ...`, `fix(ui): ...`). 10. **Concurrency and resource lifecycle (Go).** Thread `context.Context` with explicit timeout/cancellation from every HTTP handler into the runtime layer — never use bare `context.Background()` for a blocking call inside a handler. Every long-lived goroutine (log broadcasting, migration, polling) must observe cancellation/a stop signal and be stopped on server shutdown — no fire-and-forget goroutines. Release resources with `defer` right next to acquisition (`defer f.Close()`, `defer mu.Unlock()`, `defer cancel()`). 11. **Versioning and living docs.** Bump `backend/version/version.go` and `ui/pubspec.yaml` together for every release. Add a `CHANGELOG.md` entry for every user-visible change. Whenever a file is added/removed/renamed under `backend/` or `ui/lib/`, update the Project layout tree and file reference sections in both `CLAUDE.md` and `.cursor/rules/calf.mdc` in the same change — keep both files equivalent and in sync with the codebase. +12. **CHANGELOG in user language.** CHANGELOG entries must describe changes from the user's perspective — no implementation details, library names, file paths, or protocol jargon. Write what users see and feel, not how it was built. ## Before making changes diff --git a/CHANGELOG.md b/CHANGELOG.md index 42e1a55..e810d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,64 +5,61 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.4.0] - 2026-07-05 +## [0.6.0] - 2026-07-06 + +### Changed + +- **Proxy settings** redesigned with clearer layout, icons for each field, and one-tap clear buttons + +## [0.5.0] - 2026-07-05 ### Added -- Volume **Exports** tab with export history and download -- **Quick export** to a local tar file, an existing local image, a new image built from the volume, or a registry push -- Export file/image name patterns with `{volume}` and `{timestamp}` placeholders (static names allowed with overwrite warning) -- **Scheduled exports** with per-weekday times, enable/disable from the schedule list, and a background scheduler in the daemon -- Backend volume export API: `GET/POST /v1/volumes/{name}/exports` and `GET .../exports/{id}/download` -- Backend schedule API: `GET/POST /v1/volumes/{name}/export-schedules` and `PUT/DELETE .../export-schedules/{id}` +- **Networks screen** in the sidebar — browse networks and see their details +- **Proxy settings** in Settings — configure HTTP, HTTPS, and no-proxy for image downloads +- **host.docker.internal** now works from inside containers on macOS +- Port forwarding from containers now recovers automatically after sleep/wake -## [0.3.0] - 2026-07-01 +## [0.4.0] - 2026-07-05 ### Added -- Lima VM runtime on macOS with containerd, nerdctl, and Docker socket forwarding -- Linux native runtime path via nerdctl -- `calf start`, `calf stop`, and `calf status` CLI commands -- Container and image REST API endpoints -- WebSocket container log streaming at `/v1/containers/{id}/logs` -- Flutter UI screens for containers, images, and live logs -- `examples/hello-world/` reference project -- `scripts/verify-docker-cli.sh` P0 Docker CLI verification script -- Docker Desktop migration guide in `DEVELOPMENT.md` +- **Volume exports** — download a snapshot of any volume as a tar file +- **Quick export** — send a volume to a local file, an existing image, a new image, or a registry +- **Scheduled exports** — set up daily, weekly, or monthly exports with per-weekday schedules -### Changed +## [0.3.0] - 2026-07-01 + +### Added -- `/v1/status` now includes runtime mode, state, and Docker socket path -- Go tests moved to `backend/test/` +- First usable release with container and image management +- **macOS support** via a managed Linux VM +- **Linux support** running directly on the host +- Start, stop, and check status from the command line +- **Containers screen** — list, start, stop, delete containers +- **Images screen** — browse and delete images +- **Live logs** — stream container logs in real time +- Reference project in `examples/hello-world/` ## [0.2.0] - 2026-07-01 ### Added -- Daemon layout under `backend/cmd/calf` with `internal/api` and `internal/config` -- Versioned REST API: `GET /v1/health`, `GET /v1/status` -- Persistent configuration at `~/.config/calf/config.yaml` -- Structured logging (`slog`), request logging, and panic recovery middleware -- Flutter UI with sidebar navigation, daemon status, and read-only settings screens -- `Makefile` for local builds (`make backend`, `make ui`, `make build`) -- GitHub Actions CI on macOS for backend and Flutter UI +- Basic sidebar navigation between screens +- **Settings screen** — read-only view of configuration +- Status banner showing daemon connection state +- Configuration file saved at `~/.config/calf/config.yaml` +- Build scripts for development ### Changed -- Backend entry point moved from `go run .` to `go run ./cmd/calf` -- UI now consumes `/v1/status` instead of `/hello` - -### Removed - -- `/hello` placeholder endpoint +- Backend restructured for easier development ## [0.1.0] - 2026-07-01 ### Added -- Project scaffolding -- Go backend (`backend/`) with HTTP API -- CORS support on API routes for local UI development -- Flutter UI (`ui/`) with `shadcn_ui`, fetching daemon response on startup -- `DEVELOPMENT.md` with quick-start instructions for backend and UI -- `README.md` with project overview and MIT license reference +- Project bootstrap with Go backend and Flutter UI +- Health check endpoint +- CORS support for local development +- Quick-start guide in `DEVELOPMENT.md` diff --git a/CLAUDE.md b/CLAUDE.md index cf076db..11afa84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ calf/ │ │ │ ├── images.go Image list/inspect/remove endpoints │ │ │ ├── logs.go Log streaming endpoints (WebSocket) │ │ │ ├── volumes.go Volume CRUD + subresources +│ │ │ ├── networks.go Network list/inspect/remove endpoints │ │ │ ├── migrate.go Docker Desktop migration orchestration + status polling │ │ │ ├── registry.go Registry login/logout │ │ │ └── registry_login.go Docker Hub OAuth device-flow browser login @@ -78,6 +79,8 @@ calf/ │ │ │ ├── lima.go Lima runtime: manages the Lima VM, runs ops via `limactl shell ... nerdctl` │ │ │ ├── lima.yaml Embedded Lima VM template (go:embed) │ │ │ ├── nerdctl.go Shared nerdctl output parsing, compose project inference, log filtering +│ │ │ ├── network.go Network list/inspect/remove via nerdctl +│ │ │ ├── proxy.go HTTP/HTTPS proxy application in VM/native runtime │ │ │ ├── mock.go In-memory Runtime implementation used by backend tests │ │ │ ├── exec.go exec.CommandContext wrapper with transient-error retry │ │ │ ├── exec_attach.go PTY-based interactive exec attach (stdin/stdout/resize) @@ -120,6 +123,7 @@ calf/ │ │ │ ├── container_detail_screen.dart Tabs: logs/inspect/mounts/exec/files/stats (fl_chart, xterm) │ │ │ ├── compose_group_detail_screen.dart Mixed-color log view per compose project │ │ │ ├── resources_screen.dart Images/Volumes/Builds screens +│ │ │ ├── networks_screen.dart Network list and detail screens │ │ │ ├── volume_detail_screen.dart Stored-data / containers-in-use / exports tabs │ │ │ ├── volume_quick_export_screen.dart Quick export destination picker │ │ │ └── volume_schedule_export_screen.dart Schedule export configuration @@ -152,7 +156,7 @@ calf/ Entrypoint. Loads config, builds the logger, constructs the `runtime.Runtime` and `api.Server`, handles `SIGINT`/`SIGTERM` via `signal.NotifyContext`, manages a PID file at `~/.config/calf/calf.pid`, and has `ensurePort` logic that terminates a stale previous `calf` process holding the listen port before starting. The runtime starts asynchronously in a goroutine (failure is non-fatal at startup); shutdown stops both the HTTP server and the runtime with timeouts. ### `internal/config/` -- `config.go` — defines the `Config` struct (`listen_addr`, `log_level`, `vm_name`, `docker_socket`, `poll_interval_ms`, `cpus`, `memory_gb`, `memory_swap_gb`, `disk_gb`). Loads/saves as YAML at `~/.config/calf/config.yaml`, with defaults embedded via `//go:embed config.yaml`, a `withDefaults` backfill step, and `migrateLegacyDefaults` (rewrites the old `:8080` port to `:8765`). +- `config.go` — defines the `Config` struct (`listen_addr`, `log_level`, `vm_name`, `docker_socket`, `poll_interval_ms`, `cpus`, `memory_gb`, `memory_swap_gb`, `disk_gb`, `http_proxy`, `https_proxy`, `no_proxy`). Loads/saves as YAML at `~/.config/calf/config.yaml`, with defaults embedded via `//go:embed config.yaml`, a `withDefaults` backfill step, and `migrateLegacyDefaults` (rewrites the old `:8080` port to `:8765`). - `logger.go` — wraps `slog.NewTextHandler` with a level parser (`debug`/`warn`/`error`, default `info`). ### `internal/api/` @@ -167,7 +171,7 @@ HTTP server built on `net/http.ServeMux`. Every handler follows the same shape: - `runtime_ready.go` — blocks until the runtime is running (3-minute timeout); used before registry login. - `runtime_errors.go` — maps `ErrRuntimeNotRunning` to `503`. - `builds.go` — in-memory build history; `POST` triggers `RunBuild`. -- `containers.go` / `exec.go` / `images.go` / `logs.go` / `volumes.go` — CRUD plus subresources (logs, inspect, mounts, files, exec, stats). Exec/logs use WebSocket; other operations use one-shot HTTP. +- `containers.go` / `exec.go` / `images.go` / `logs.go` / `volumes.go` / `networks.go` — CRUD plus subresources (logs, inspect, mounts, files, exec, stats). Exec/logs use WebSocket; other operations use one-shot HTTP. - `migrate.go` — orchestrates the Docker Desktop migration in a background goroutine, exposes status polling. - `registry.go` / `registry_login.go` — basic registry login/logout plus Docker Hub OAuth device-flow browser login with session polling. @@ -187,7 +191,7 @@ Docker Hub OAuth2 device-code flow client. Polls for a token, decodes JWT claims ### `internal/runtime/` (core abstraction) - `runtime.go` — defines the `Runtime` interface (~30 methods: lifecycle, containers, images, volumes, builds, logs, exec, stats, registry) and shared JSON-tagged (snake_case) types (`Status`, `Container`, `Image`, `Volume`, `Build`, ...). `runtime.New(...)` selects `NewNative` on Linux, otherwise `NewLima`. - `native.go` — `Native` runtime: talks directly to a `nerdctl`/`docker.sock` already available on the Linux host, no VM involved. -- `lima.go` — `Lima` runtime: manages a Lima VM (embeds a `lima.yaml` template via `go:embed`), starts/creates/stops the instance via `limactl`, runs all container operations via `limactl shell -- sudo nerdctl ...`. Includes `localhostProxies` for macOS port-forwarding and conflict detection. +- `lima.go` — `Lima` runtime: manages a Lima VM (embeds a `lima.yaml` template via `go:embed`), starts/creates/stops the instance via `limactl`, runs all container operations via `limactl shell -- sudo nerdctl ...`. Includes `localhostProxies` for macOS port-forwarding and conflict detection, `host.docker.internal` via Lima `hostResolver`, sleep/wake proxy resync, and proxy application. - `nerdctl.go` — shared low-level helpers: JSON-line parsing of `nerdctl ps/images/volume ls/history` output, compose project/service inference, log-line noise filtering, log streaming plumbing. - `mock.go` — in-memory `Mock` implementation of the full `Runtime` interface, used by backend tests. - `exec.go` — generic `exec.CommandContext` wrapper with retry logic (`runCommandWithRetry`, retries only on `isTransientCommandError`). @@ -225,6 +229,7 @@ Simple JSON files under `~/.config/calf/ui/.json` (via `path_provider`'s a - `container_detail_screen.dart` — tabs for logs/inspect/mounts/exec/files/stats, using `fl_chart` and `xterm`. - `compose_group_detail_screen.dart` — mixed-color log view per compose project. - `resources_screen.dart` — Images/Volumes/Builds screens. +- `networks_screen.dart` — Network list (name + subnet) and detail (driver, scope, gateway, options). - `volume_detail_screen.dart` — stored-data / containers-in-use / exports tabs. - `volume_quick_export_screen.dart` — quick export destination picker (local file, image, registry). - `volume_schedule_export_screen.dart` — schedule export configuration (daily/weekly/monthly). @@ -261,6 +266,7 @@ CI (`.github/workflows/ci.yml`, both jobs on `macos-latest`): - **Comments:** English only, and only where the *why* isn't obvious from the code itself. Do not restate what the code already says. - **Fix root causes, not symptoms.** When you encounter a bug or a design problem, find and eliminate or replace the underlying cause. Do not apply superficial patches, workarounds, or defensive band-aids that mask the real issue — this includes silently swallowing errors, adding retries around a fundamentally broken call, or special-casing a symptom instead of fixing the source. - **Commit style:** conventional-commit-like prefixes (`feat:`, `fix:`, `refactor:`, `chore:`), occasionally scoped (`fix(ui):`, `feat(runtime):`). +- **CHANGELOG:** entries must describe changes in user-facing terms — no implementation details, library names, file paths, or protocol jargon. Write what changed from the user's perspective, not how it was built. - **Error handling (backend):** handlers never leak raw internal errors to clients; they go through `writeRuntimeError`/`writeJSON` and map to appropriate HTTP status codes. - **No generic catch-alls.** Never write a generic `try/catch` (or, in Go, a generic error check that just forwards `err` without identifying what failed). Catch/handle each *specific* error case individually — if that means 3, 5, or 10 separate specific handlers, write all of them. The point is that whoever reads the error (logs, UI, API response) can tell exactly which operation failed and why, not just that "something went wrong". - **No premature abstraction:** the runtime layer has exactly three implementations (`Native`, `Lima`, `Mock`) behind one interface — follow that pattern rather than introducing new abstraction layers for hypothetical future runtimes. diff --git a/ROADMAP.md b/ROADMAP.md index 50ecac3..3296914 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,8 +109,8 @@ Be a **valid** Docker Desktop replacement for local development: same CLI (`dock - [x] `docker compose` v2 support (plugin pointing at Calf socket) - [x] Bridge networks between services (engine-level; validated on real stacks) - [x] Named volumes and bind mounts with acceptable performance (virtiofs in Lima) -- [ ] `host.docker.internal` on macOS -- [ ] Stable port mapping after sleep/wake +- [x] `host.docker.internal` on macOS +- [x] Stable port mapping after sleep/wake - [x] localhost port conflict detection and proxy (macOS; API port reservation) ### 2.2 Docker Desktop migration @@ -128,7 +128,7 @@ Be a **valid** Docker Desktop replacement for local development: same CLI (`dock - [x] Image layers, run, and push actions - [x] Builds list (history persisted to disk) - [x] Docker Hub registry login (device flow) -- [ ] Network management UI +- [x] Network management UI - [x] Build detail view UI **Exit criteria:** 3 reference stacks (LAMP, Node+Postgres, Laravel Sail) start with `docker compose up -d` without modifications. @@ -150,13 +150,13 @@ Be a **valid** Docker Desktop replacement for local development: same CLI (`dock ### 3.2 Settings - [x] CPU/RAM/disk limits for the VM -- [ ] HTTP/HTTPS proxy +- [x] HTTP/HTTPS proxy - [x] Auto-start on login (optional) ### 3.3 Full UI - [x] Volume management -- [ ] Network management +- [x] Network management - [x] Consistent light/dark theme (shadcn_ui) - [ ] Keyboard shortcuts and basic accessibility - [ ] MacOS MenuActions (topbar actions exist; native menu integration pending) diff --git a/backend/cmd/calf/main.go b/backend/cmd/calf/main.go index 4ea3bd8..1d021b6 100644 --- a/backend/cmd/calf/main.go +++ b/backend/cmd/calf/main.go @@ -31,7 +31,20 @@ func run() int { } logger := config.NewLogger(cfg.LogLevel) - rt := runtime.New(cfg.VMName, cfg.DockerSocket, cfg.CPUs, cfg.MemoryGB, cfg.MemorySwapGB, cfg.DiskGB, runtime.ParseListenPort(cfg.ListenAddr)) + rt := runtime.New( + cfg.VMName, + cfg.DockerSocket, + cfg.CPUs, + cfg.MemoryGB, + cfg.MemorySwapGB, + cfg.DiskGB, + runtime.ParseListenPort(cfg.ListenAddr), + runtime.ProxyConfig{ + HTTPProxy: cfg.HTTPProxy, + HTTPSProxy: cfg.HTTPSProxy, + NoProxy: cfg.NoProxy, + }, + ) server := api.New(cfg, logger, rt) if err := ensurePort(cfg.ListenAddr); err != nil { diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 2212562..32d8841 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -3,8 +3,11 @@ package api import ( "context" "encoding/json" + "fmt" goruntime "runtime" + "net" "net/http" + "net/url" "os/exec" "strconv" "strings" @@ -58,13 +61,19 @@ type configResponse struct { DockerContextActive bool `json:"docker_context_active"` DockerContextName string `json:"docker_context_name"` DockerCLIAvailable bool `json:"docker_cli_available"` + HTTPProxy string `json:"http_proxy"` + HTTPSProxy string `json:"https_proxy"` + NoProxy string `json:"no_proxy"` } type configUpdateRequest struct { - CPUs *int `json:"cpus,omitempty"` - MemoryGB *int `json:"memory_gb,omitempty"` - MemorySwapGB *int `json:"memory_swap_gb,omitempty"` - DockerContextManaged *bool `json:"docker_context_managed,omitempty"` + CPUs *int `json:"cpus,omitempty"` + MemoryGB *int `json:"memory_gb,omitempty"` + MemorySwapGB *int `json:"memory_swap_gb,omitempty"` + DockerContextManaged *bool `json:"docker_context_managed,omitempty"` + HTTPProxy *string `json:"http_proxy,omitempty"` + HTTPSProxy *string `json:"https_proxy,omitempty"` + NoProxy *string `json:"no_proxy,omitempty"` } func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { @@ -84,6 +93,11 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { return } + if err := validateProxyUpdate(req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.cfgMu.Lock() if req.CPUs != nil { s.cfg.CPUs = *req.CPUs @@ -97,6 +111,22 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { if req.DockerContextManaged != nil { s.cfg.DockerContextManaged = *req.DockerContextManaged } + if req.HTTPProxy != nil { + s.cfg.HTTPProxy = strings.TrimSpace(*req.HTTPProxy) + } + if req.HTTPSProxy != nil { + s.cfg.HTTPSProxy = strings.TrimSpace(*req.HTTPSProxy) + } + if req.NoProxy != nil { + s.cfg.NoProxy = strings.TrimSpace(*req.NoProxy) + } + + proxyChanged := req.HTTPProxy != nil || req.HTTPSProxy != nil || req.NoProxy != nil + savedProxy := runtime.ProxyConfig{ + HTTPProxy: s.cfg.HTTPProxy, + HTTPSProxy: s.cfg.HTTPSProxy, + NoProxy: s.cfg.NoProxy, + } if err := config.Save(s.cfg); err != nil { s.cfgMu.Unlock() @@ -107,16 +137,26 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { activateManaged := s.cfg.DockerContextManaged s.cfgMu.Unlock() + writeJSON(w, http.StatusOK, s.configResponse()) + + if proxyChanged { + go func() { + proxyCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := s.runtime.ApplyProxy(proxyCtx, savedProxy); err != nil { + s.logger.Warn("failed to apply proxy settings", "error", err) + } + }() + } + if activateManaged { - activateCtx, cancel := context.WithTimeout(r.Context(), dockerContextTimeout) + activateCtx, cancel := context.WithTimeout(context.Background(), dockerContextTimeout) defer cancel() if err := s.activateDockerContext(activateCtx); err != nil { s.logger.Warn("failed to activate docker context", "error", err) } } - writeJSON(w, http.StatusOK, s.configResponse()) - default: writeError(w, http.StatusMethodNotAllowed, "method not allowed") } @@ -162,6 +202,121 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { }) } +func validateProxyUpdate(req configUpdateRequest) error { + if req.HTTPProxy != nil { + v := strings.TrimSpace(*req.HTTPProxy) + if v != "" { + if err := validateProxyURL(v, "http"); err != nil { + return fmt.Errorf("http_proxy: %w", err) + } + } + } + if req.HTTPSProxy != nil { + v := strings.TrimSpace(*req.HTTPSProxy) + if v != "" { + if err := validateProxyURL(v, "http", "https"); err != nil { + return fmt.Errorf("https_proxy: %w", err) + } + } + } + if req.NoProxy != nil { + v := strings.TrimSpace(*req.NoProxy) + if v != "" { + for _, entry := range strings.Split(v, ",") { + entry = strings.TrimSpace(entry) + if entry != "" { + if err := validateNoProxyEntry(entry); err != nil { + return fmt.Errorf("no_proxy: %w", err) + } + } + } + } + } + return nil +} + +func validateProxyURL(raw string, allowedSchemes ...string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid URL %q: %w", raw, err) + } + + if u.Scheme == "" { + return fmt.Errorf("missing scheme in %q (expected http:// or https://)", raw) + } + + schemeOK := false + for _, s := range allowedSchemes { + if u.Scheme == s { + schemeOK = true + break + } + } + if !schemeOK { + return fmt.Errorf("unsupported scheme %q in %q", u.Scheme, raw) + } + + if u.Host == "" { + return fmt.Errorf("missing host in %q", raw) + } + + return nil +} + +func validateNoProxyEntry(entry string) error { + if strings.Contains(entry, "/") { + return fmt.Errorf("invalid no_proxy entry %q: must not contain a path", entry) + } + + host := entry + if strings.HasPrefix(host, ".") { + host = host[1:] + } + + if net.ParseIP(host) != nil { + return nil + } + + if h, _, err := net.SplitHostPort(host); err == nil { + if net.ParseIP(h) != nil { + return nil + } + host = h + } + + if isValidDomain(host) { + return nil + } + + return fmt.Errorf("invalid no_proxy entry %q: must be a valid hostname or IP address", entry) +} + +func isValidDomain(host string) bool { + if host == "" || len(host) > 253 { + return false + } + + for _, part := range strings.Split(host, ".") { + if part == "" || len(part) > 63 { + return false + } + for i, r := range part { + if i == 0 && r == '-' { + return false + } + if i == len(part)-1 && r == '-' { + return false + } + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '_') { + return false + } + } + } + + return true +} + func (s *Server) configResponse() configResponse { cliStatus, _ := s.dockerCLIStatus() @@ -180,5 +335,8 @@ func (s *Server) configResponse() configResponse { DockerContextActive: cliStatus.CalfActive, DockerContextName: cliStatus.CurrentContext, DockerCLIAvailable: cliStatus.Available, + HTTPProxy: cfg.HTTPProxy, + HTTPSProxy: cfg.HTTPSProxy, + NoProxy: cfg.NoProxy, } } diff --git a/backend/internal/api/networks.go b/backend/internal/api/networks.go new file mode 100644 index 0000000..6ba729a --- /dev/null +++ b/backend/internal/api/networks.go @@ -0,0 +1,82 @@ +package api + +import ( + "context" + "net/http" + "strings" + "time" +) + +const networkActionTimeout = 30 * time.Second + +func (s *Server) writeRuntimeOrInternalError(w http.ResponseWriter, err error) bool { + if writeRuntimeError(w, err) { + return true + } + + writeError(w, http.StatusInternalServerError, err.Error()) + return false +} + +func (s *Server) handleNetworks(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + switch r.Method { + case http.MethodGet: + ctx, cancel := context.WithTimeout(r.Context(), networkActionTimeout) + defer cancel() + + networks, err := s.runtime.ListNetworks(ctx) + if err != nil { + s.writeRuntimeOrInternalError(w, err) + return + } + + writeJSON(w, http.StatusOK, networks) + default: + methodNotAllowed(w, r) + } +} + +func (s *Server) handleNetworkAction(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + name := strings.TrimPrefix(r.URL.Path, "/v1/networks/") + name = strings.Trim(name, "/") + if name == "" { + writeError(w, http.StatusNotFound, "network not found") + return + } + + switch r.Method { + case http.MethodGet: + ctx, cancel := context.WithTimeout(r.Context(), networkActionTimeout) + defer cancel() + + detail, err := s.runtime.InspectNetwork(ctx, name) + if err != nil { + s.writeRuntimeOrInternalError(w, err) + return + } + + writeJSON(w, http.StatusOK, detail) + case http.MethodDelete: + ctx, cancel := context.WithTimeout(r.Context(), networkActionTimeout) + defer cancel() + + if err := s.runtime.RemoveNetwork(ctx, name); err != nil { + s.writeRuntimeOrInternalError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + default: + methodNotAllowed(w, r) + } +} diff --git a/backend/internal/api/runtime_errors.go b/backend/internal/api/runtime_errors.go index b139449..a87c797 100644 --- a/backend/internal/api/runtime_errors.go +++ b/backend/internal/api/runtime_errors.go @@ -13,5 +13,10 @@ func writeRuntimeError(w http.ResponseWriter, err error) bool { return true } + if errors.Is(err, runtime.ErrNetworkNotFound) { + writeError(w, http.StatusNotFound, "network not found") + return true + } + return false } diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index cf2f8e8..3af5fc9 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -63,6 +63,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/v1/images/", s.handleImageSubpath) mux.HandleFunc("/v1/volumes", s.handleVolumes) mux.HandleFunc("/v1/volumes/", s.handleVolumeAction) + mux.HandleFunc("/v1/networks", s.handleNetworks) + mux.HandleFunc("/v1/networks/", s.handleNetworkAction) mux.HandleFunc("/v1/builds", s.handleBuilds) mux.HandleFunc("/v1/builds/", s.handleBuildAction) mux.HandleFunc("/v1/registry", s.handleRegistry) diff --git a/backend/internal/buildhistory/inspect.go b/backend/internal/buildhistory/inspect.go index e93f307..6f378e2 100644 --- a/backend/internal/buildhistory/inspect.go +++ b/backend/internal/buildhistory/inspect.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "fmt" + "os" + "path" "strings" ) type InspectDetail struct { Context string Dockerfile string + Labels map[string]string } func Inspect(ctx context.Context, socket, historyID string) (InspectDetail, error) { @@ -32,37 +35,72 @@ func Inspect(ctx context.Context, socket, historyID string) (InspectDetail, erro return InspectDetail{}, err } - detail := parseInspectDetail(string(output)) - if detail.Context == "" { - return InspectDetail{}, fmt.Errorf("build history inspect: missing context") - } - - return detail, nil + return parseInspectDetail(string(output)) } -func parseInspectDetail(output string) InspectDetail { - detail := InspectDetail{Dockerfile: "Dockerfile"} +func parseInspectDetail(output string) (InspectDetail, error) { + detail := InspectDetail{Dockerfile: "Dockerfile", Labels: make(map[string]string)} output = strings.TrimSpace(output) if output == "" { - return detail + return detail, nil } - var payload map[string]string + var payload map[string]any if err := json.Unmarshal([]byte(output), &payload); err != nil { - return detail + return InspectDetail{}, fmt.Errorf("build history inspect: parse output: %w", err) } for key, value := range payload { switch strings.ToLower(key) { case "context": - detail.Context = value + if s, ok := value.(string); ok { + detail.Context = s + } case "dockerfile": - if value != "" { - detail.Dockerfile = value + if s, ok := value.(string); ok && s != "" { + detail.Dockerfile = s + } + case "labels": + if labels, ok := value.(map[string]any); ok { + for lk, lv := range labels { + if s, ok := lv.(string); ok { + detail.Labels[lk] = s + } + } + } + } + } + + if detail.Context == "" { + detail.Context = resolveContextFromLabels(detail.Labels) + } + + return detail, nil +} + +func resolveContextFromLabels(labels map[string]string) string { + if workingDir, ok := labels["com.docker.compose.project.working_dir"]; ok && workingDir != "" { + if _, err := os.Stat(workingDir); err == nil { + return workingDir + } + } + + if configFiles, ok := labels["com.docker.compose.project.config_files"]; ok { + for _, part := range strings.Split(configFiles, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, err := os.Stat(part); err == nil { + return stripLastComponent(part) } } } - return detail + return "" +} + +func stripLastComponent(p string) string { + return path.Dir(p) } diff --git a/backend/internal/buildhistory/inspect_test.go b/backend/internal/buildhistory/inspect_test.go index 9c5eace..62327c6 100644 --- a/backend/internal/buildhistory/inspect_test.go +++ b/backend/internal/buildhistory/inspect_test.go @@ -3,7 +3,11 @@ package buildhistory import "testing" func TestParseInspectDetail(t *testing.T) { - detail := parseInspectDetail(`{"Context":"/Users/egalan/git/toth","Dockerfile":"apps/api/Dockerfile"}`) + detail, err := parseInspectDetail(`{"Context":"/Users/egalan/git/toth","Dockerfile":"apps/api/Dockerfile"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if detail.Context != "/Users/egalan/git/toth" { t.Fatalf("unexpected context: %q", detail.Context) } @@ -13,14 +17,22 @@ func TestParseInspectDetail(t *testing.T) { } func TestParseInspectDetailDefaultsDockerfile(t *testing.T) { - detail := parseInspectDetail(`{"Context":"/tmp/build"}`) + detail, err := parseInspectDetail(`{"Context":"/tmp/build"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if detail.Dockerfile != "Dockerfile" { t.Fatalf("unexpected dockerfile: %q", detail.Dockerfile) } } func TestParseInspectDetailPreservesContextWithSpaces(t *testing.T) { - detail := parseInspectDetail(`{"Context":"/Users/egalan/git/my project","Dockerfile":"Dockerfile"}`) + detail, err := parseInspectDetail(`{"Context":"/Users/egalan/git/my project","Dockerfile":"Dockerfile"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if detail.Context != "/Users/egalan/git/my project" { t.Fatalf("unexpected context: %q", detail.Context) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9ff360a..47569e7 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -23,6 +23,9 @@ type Config struct { MemoryGB int `yaml:"memory_gb"` MemorySwapGB int `yaml:"memory_swap_gb"` DiskGB int `yaml:"disk_gb"` + HTTPProxy string `yaml:"http_proxy"` + HTTPSProxy string `yaml:"https_proxy"` + NoProxy string `yaml:"no_proxy"` } func Default() Config { diff --git a/backend/internal/config/config.yaml b/backend/internal/config/config.yaml index 774eb20..2b682c6 100644 --- a/backend/internal/config/config.yaml +++ b/backend/internal/config/config.yaml @@ -8,3 +8,6 @@ cpus: 4 memory_gb: 4 memory_swap_gb: 1 disk_gb: 100 +http_proxy: "" +https_proxy: "" +no_proxy: "" diff --git a/backend/internal/runtime/errors.go b/backend/internal/runtime/errors.go index 6bf815b..c96b1c1 100644 --- a/backend/internal/runtime/errors.go +++ b/backend/internal/runtime/errors.go @@ -6,6 +6,7 @@ import ( ) var ErrRuntimeNotRunning = errors.New("runtime is not running") +var ErrNetworkNotFound = errors.New("network not found") func emptyContainersIfStopped(ctx context.Context, statusFn func(context.Context) (Status, error), listFn func(context.Context) ([]Container, error)) ([]Container, error) { status, err := statusFn(ctx) @@ -46,6 +47,19 @@ func emptyVolumesIfStopped(ctx context.Context, statusFn func(context.Context) ( return listFn(ctx) } +func emptyNetworksIfStopped(ctx context.Context, statusFn func(context.Context) (Status, error), listFn func(context.Context) ([]Network, error)) ([]Network, error) { + status, err := statusFn(ctx) + if err != nil { + return nil, err + } + + if status.State != StateRunning { + return []Network{}, nil + } + + return listFn(ctx) +} + func requireRunning(ctx context.Context, statusFn func(context.Context) (Status, error)) error { status, err := statusFn(ctx) if err != nil { diff --git a/backend/internal/runtime/lima.go b/backend/internal/runtime/lima.go index 504b0d1..40636c5 100644 --- a/backend/internal/runtime/lima.go +++ b/backend/internal/runtime/lima.go @@ -6,19 +6,25 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net" "os" "os/exec" "path/filepath" "strings" + "sync" "sync/atomic" "time" ) +var limaLogger = slog.Default() + //go:embed lima.yaml var limaTemplate string type Lima struct { + mu sync.Mutex + vmName string dockerSocket string templatePath string @@ -26,11 +32,15 @@ type Lima struct { memoryGB int memorySwapGB int diskGB int + proxy ProxyConfig started atomic.Bool + proxyResync atomic.Bool + lastState State localhostProxy *localhostProxies + watcherCancel context.CancelFunc } -func NewLima(vmName string, dockerSocket string, cpus int, memoryGB int, memorySwapGB int, diskGB int, apiListenPort int) *Lima { +func NewLima(vmName string, dockerSocket string, cpus int, memoryGB int, memorySwapGB int, diskGB int, apiListenPort int, proxy ProxyConfig) *Lima { if vmName == "" { vmName = "calf" } @@ -46,6 +56,7 @@ func NewLima(vmName string, dockerSocket string, cpus int, memoryGB int, memoryS memoryGB: memoryGB, memorySwapGB: memorySwapGB, diskGB: diskGB, + proxy: proxy, localhostProxy: newLocalhostProxies(), } lima.localhostProxy.setReservedPorts(apiListenPort) @@ -85,7 +96,16 @@ func (l *Lima) Start(ctx context.Context) error { return err } + if l.proxy != (ProxyConfig{}) { + if err := l.ApplyProxy(ctx, l.proxy); err != nil { + limaLogger.Warn("proxy application during start failed (non-fatal)", "error", err) + } + } + l.started.Store(true) + wCtx, wCancel := context.WithCancel(context.Background()) + l.watcherCancel = wCancel + go l.watchPortProxies(wCtx) return nil } @@ -103,6 +123,11 @@ func (l *Lima) Stop(ctx context.Context) error { return nil } + if l.watcherCancel != nil { + l.watcherCancel() + l.watcherCancel = nil + } + l.localhostProxy.stopAll() l.started.Store(false) @@ -147,6 +172,14 @@ func (l *Lima) Status(ctx context.Context) (Status, error) { } } + l.mu.Lock() + if status.State == StateRunning && l.lastState != StateRunning { + l.started.Store(true) + l.proxyResync.Store(true) + } + + l.lastState = status.State + l.mu.Unlock() status.PortConflicts = l.localhostProxy.conflictsSnapshot() return status, nil @@ -160,7 +193,11 @@ func (l *Lima) ListContainers(ctx context.Context) ([]Container, error) { containers, err := listContainers(ctx, l.runInVM) if err == nil { - l.localhostProxy.sync(publishedTCPPorts(containers)) + force := l.proxyResync.Load() + l.localhostProxy.sync(publishedTCPPorts(containers), force) + if force { + l.proxyResync.Store(false) + } } return containers, err @@ -192,6 +229,69 @@ func (l *Lima) ListVolumes(ctx context.Context) ([]Volume, error) { }) } +func (l *Lima) ListNetworks(ctx context.Context) ([]Network, error) { + return emptyNetworksIfStopped(ctx, l.Status, func(ctx context.Context) ([]Network, error) { + return listNetworks(ctx, l.runInVM) + }) +} + +func (l *Lima) InspectNetwork(ctx context.Context, name string) (NetworkDetail, error) { + if err := requireRunning(ctx, l.Status); err != nil { + return NetworkDetail{}, err + } + + return inspectNetwork(ctx, l.runInVM, name) +} + +func (l *Lima) RemoveNetwork(ctx context.Context, name string) error { + if err := requireRunning(ctx, l.Status); err != nil { + return err + } + + return removeNetwork(ctx, l.runInVM, name) +} + +func (l *Lima) ApplyProxy(ctx context.Context, proxy ProxyConfig) error { + l.mu.Lock() + l.proxy = proxy + l.mu.Unlock() + + if err := requireRunning(ctx, l.Status); err != nil { + return nil + } + + return applyProxyInVM(ctx, l.runInVM, proxy) +} + +func (l *Lima) watchPortProxies(ctx context.Context) { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + status, err := l.Status(ctx) + if err != nil || status.State != StateRunning || !l.started.Load() { + continue + } + + containers, err := listContainers(ctx, l.runInVM) + if err != nil { + l.proxyResync.Store(true) + continue + } + + force := l.proxyResync.Load() + l.localhostProxy.sync(publishedTCPPorts(containers), force) + if force { + l.proxyResync.Store(false) + } + } + } +} + func (l *Lima) CreateVolume(ctx context.Context, name string) error { if err := requireRunning(ctx, l.Status); err != nil { return err @@ -519,7 +619,11 @@ func (l *Lima) ensureTemplate() error { diskGB = 100 } - content := fmt.Sprintf(limaTemplate, home, home, l.cpus, l.memoryGB, l.memorySwapGB, diskGB) + l.mu.Lock() + currentProxy := l.proxy + l.mu.Unlock() + + content := fmt.Sprintf(limaTemplate, home, home, l.cpus, l.memoryGB, l.memorySwapGB, diskGB, currentProxy.HTTPProxy, currentProxy.HTTPSProxy, currentProxy.NoProxy) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return err } diff --git a/backend/internal/runtime/lima.yaml b/backend/internal/runtime/lima.yaml index c6e091f..3e63edb 100644 --- a/backend/internal/runtime/lima.yaml +++ b/backend/internal/runtime/lima.yaml @@ -26,6 +26,10 @@ mounts: ssh: loadDotSSHPubKeys: true +hostResolver: + hosts: + host.docker.internal: host.lima.internal + provision: - mode: system script: | @@ -58,6 +62,26 @@ provision: grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab fi nerdctl run --rm hello-world || true + sudo mkdir -p /etc/systemd/system/containerd.service.d + sudo tee /etc/systemd/system/containerd.service.d/calf-proxy.conf >/dev/null <<'EOF' + [Service] + Environment="HTTP_PROXY=%[7]s" + Environment="HTTPS_PROXY=%[8]s" + Environment="NO_PROXY=%[9]s" + Environment="http_proxy=%[7]s" + Environment="https_proxy=%[8]s" + Environment="no_proxy=%[9]s" + EOF + sudo tee /etc/profile.d/calf-proxy.sh >/dev/null <<'EOF' + export HTTP_PROXY=%[7]s + export HTTPS_PROXY=%[8]s + export NO_PROXY=%[9]s + export http_proxy=%[7]s + export https_proxy=%[8]s + export no_proxy=%[9]s + EOF + sudo systemctl daemon-reload + sudo systemctl restart containerd docker portForwards: - guestSocket: "/var/run/docker.sock" diff --git a/backend/internal/runtime/localhost_proxy.go b/backend/internal/runtime/localhost_proxy.go index 8a7cec6..7423936 100644 --- a/backend/internal/runtime/localhost_proxy.go +++ b/backend/internal/runtime/localhost_proxy.go @@ -97,7 +97,7 @@ func (p *localhostProxies) stopAll() { p.conflicts = make(map[int]PortConflict) } -func (p *localhostProxies) sync(ports map[int]struct{}) { +func (p *localhostProxies) sync(ports map[int]struct{}, force bool) { if p == nil { return } @@ -105,6 +105,14 @@ func (p *localhostProxies) sync(ports map[int]struct{}) { p.mu.Lock() defer p.mu.Unlock() + if force { + for port, listener := range p.listeners { + _ = listener.Close() + delete(p.listeners, port) + } + p.conflicts = make(map[int]PortConflict) + } + for port, listener := range p.listeners { if _, ok := ports[port]; ok { continue diff --git a/backend/internal/runtime/mock.go b/backend/internal/runtime/mock.go index 0cb5078..9a39bad 100644 --- a/backend/internal/runtime/mock.go +++ b/backend/internal/runtime/mock.go @@ -16,6 +16,7 @@ type Mock struct { Containers []Container Images []Image Volumes []Volume + Networks []Network StartErr error StopErr error StatusErr error @@ -24,6 +25,8 @@ type Mock struct { ContainerErr error ExportVolumeErr error ImageErr error + NetworksErr error + NetworkErr error LogLines []string Started bool registryLoggedIn bool @@ -57,6 +60,9 @@ func NewMock() *Mock { Volumes: []Volume{ {Name: "calf-data", Driver: "local"}, }, + Networks: []Network{ + {ID: "9d1ce4c80488", Name: "bridge", Driver: "bridge", Scope: "local", Subnet: "192.168.215.0/24", Created: "9 months ago"}, + }, LogLines: []string{"hello", "world"}, } } @@ -654,3 +660,79 @@ func (m *Mock) RegistryLogout(_ context.Context, _ string) error { m.registryLoggedIn = false return nil } + +func (m *Mock) ListNetworks(_ context.Context) ([]Network, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.StatusValue.State != StateRunning { + return []Network{}, nil + } + + if m.NetworksErr != nil { + return nil, m.NetworksErr + } + + return append([]Network(nil), m.Networks...), nil +} + +func (m *Mock) InspectNetwork(_ context.Context, name string) (NetworkDetail, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.StatusValue.State != StateRunning { + return NetworkDetail{}, ErrRuntimeNotRunning + } + + if m.NetworkErr != nil { + return NetworkDetail{}, m.NetworkErr + } + + for _, network := range m.Networks { + if network.Name != name { + continue + } + + return NetworkDetail{ + ID: network.ID, + Name: network.Name, + Driver: network.Driver, + Scope: network.Scope, + Subnet: network.Subnet, + Gateway: "192.168.215.1", + Created: network.Created, + Options: map[string]string{ + "com.docker.network.bridge.default_bridge": "true", + }, + }, nil + } + + return NetworkDetail{}, ErrNetworkNotFound +} + +func (m *Mock) RemoveNetwork(_ context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.StatusValue.State != StateRunning { + return ErrRuntimeNotRunning + } + + if m.NetworkErr != nil { + return m.NetworkErr + } + + filtered := make([]Network, 0, len(m.Networks)) + for _, network := range m.Networks { + if network.Name != name { + filtered = append(filtered, network) + } + } + + m.Networks = filtered + return nil +} + +func (m *Mock) ApplyProxy(_ context.Context, _ ProxyConfig) error { + return nil +} diff --git a/backend/internal/runtime/native.go b/backend/internal/runtime/native.go index 1d92f86..45cbbb2 100644 --- a/backend/internal/runtime/native.go +++ b/backend/internal/runtime/native.go @@ -12,14 +12,15 @@ import ( type Native struct { dockerSocket string + proxy ProxyConfig } -func NewNative(_ string, dockerSocket string, _, _, _, _ int) *Native { +func NewNative(_ string, dockerSocket string, _, _, _, _ int, proxy ProxyConfig) *Native { if dockerSocket == "" { dockerSocket = "/var/run/docker.sock" } - return &Native{dockerSocket: dockerSocket} + return &Native{dockerSocket: dockerSocket, proxy: proxy} } func (n *Native) DockerSocket() string { @@ -98,6 +99,38 @@ func (n *Native) ListVolumes(ctx context.Context) ([]Volume, error) { }) } +func (n *Native) ListNetworks(ctx context.Context) ([]Network, error) { + return emptyNetworksIfStopped(ctx, n.Status, func(ctx context.Context) ([]Network, error) { + return listNetworks(ctx, n.runLocal) + }) +} + +func (n *Native) InspectNetwork(ctx context.Context, name string) (NetworkDetail, error) { + if err := requireRunning(ctx, n.Status); err != nil { + return NetworkDetail{}, err + } + + return inspectNetwork(ctx, n.runLocal, name) +} + +func (n *Native) RemoveNetwork(ctx context.Context, name string) error { + if err := requireRunning(ctx, n.Status); err != nil { + return err + } + + return removeNetwork(ctx, n.runLocal, name) +} + +func (n *Native) ApplyProxy(ctx context.Context, proxy ProxyConfig) error { + n.proxy = proxy + + if err := requireRunning(ctx, n.Status); err != nil { + return nil + } + + return applyProxyInVM(ctx, n.runLocal, proxy) +} + func (n *Native) CreateVolume(ctx context.Context, name string) error { if err := requireRunning(ctx, n.Status); err != nil { return err diff --git a/backend/internal/runtime/network.go b/backend/internal/runtime/network.go new file mode 100644 index 0000000..b399a91 --- /dev/null +++ b/backend/internal/runtime/network.go @@ -0,0 +1,484 @@ +package runtime + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "sort" + "strings" +) + +type networkLine struct { + ID string `json:"ID"` + Name string `json:"Name"` + Driver string `json:"Driver"` +} + +type networkInspectRow struct { + ID string `json:"Id"` + Name string `json:"Name"` + Created string `json:"Created"` + Scope string `json:"Scope"` + Driver string `json:"Driver"` + IPAM networkIPAM `json:"IPAM"` + Options map[string]string `json:"Options"` + Labels map[string]string `json:"Labels"` +} + +type networkIPAM struct { + Config []networkIPAMConfig `json:"Config"` +} + +type networkIPAMConfig struct { + Subnet string `json:"Subnet"` + Gateway string `json:"Gateway"` +} + +type nativeNetworkInspect struct { + CNI nativeNetworkCNI `json:"CNI"` +} + +type nativeNetworkCNI struct { + Name string `json:"name"` + Plugins []nativeCNIPlugin `json:"plugins"` +} + +type nativeCNIPlugin struct { + Type string `json:"type"` + Bridge string `json:"bridge"` + IsGateway bool `json:"isGateway"` + IPMasq bool `json:"ipMasq"` + HairpinMode bool `json:"hairpinMode"` + MTU int `json:"mtu"` + IPAM nativeCNIIPAM `json:"ipam"` +} + +type nativeCNIIPAM struct { + Ranges [][][]nativeCNIIPAMRange `json:"ranges"` +} + +type nativeCNIIPAMRange struct { + Subnet string `json:"subnet"` + Gateway string `json:"gateway"` +} + +func isPseudoNetwork(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case "host", "none", "null": + return true + default: + return false + } +} + +// IsPseudoNetwork reports whether name refers to a built-in Docker pseudo network. +func IsPseudoNetwork(name string) bool { + return isPseudoNetwork(name) +} + +func listNetworks(ctx context.Context, run commandRunner) ([]Network, error) { + networks, nerdctlErr := queryNerdctlNetworks(ctx, run) + if nerdctlErr != nil { + dockerNetworks, dockerErr := queryDockerNetworks(ctx, run) + if dockerErr != nil { + return nil, nerdctlErr + } + + networks = dockerNetworks + } else { + dockerExtra, err := queryDockerNetworks(ctx, run) + if err == nil { + networks = mergeNetworksByName(networks, dockerExtra) + } + } + + if len(networks) == 0 { + return networks, nil + } + + names := make([]string, 0, len(networks)) + for _, network := range networks { + names = append(names, network.Name) + } + + metadata, err := networkInspectMetadataBatch(ctx, run, names) + if err != nil { + return networks, nil + } + + for index := range networks { + row, ok := metadata[networks[index].Name] + if !ok { + if networks[index].Scope == "" { + networks[index].Scope = defaultNetworkScope() + } + continue + } + + networks[index].ID = shortNetworkID(firstNonEmpty(row.ID, networks[index].ID)) + driver := strings.TrimSpace(row.Driver) + if driver == "" { + driver = row.NativeDriver + } + if driver != "" { + networks[index].Driver = driver + } + if scope := strings.TrimSpace(row.Scope); scope != "" { + networks[index].Scope = scope + } else { + networks[index].Scope = defaultNetworkScope() + } + networks[index].Subnet = firstSubnet(row) + if networks[index].Subnet == "" { + networks[index].Subnet = row.NativeSubnet + } + if created := humanizeTime(row.Created); created != "" { + networks[index].Created = created + } + } + + return networks, nil +} + +func inspectNetwork(ctx context.Context, run commandRunner, name string) (NetworkDetail, error) { + if isPseudoNetwork(name) { + return NetworkDetail{}, fmt.Errorf("built-in network %q cannot be inspected", name) + } + + driver, _ := networkDriverFromList(ctx, run, name) + + row, err := inspectNetworkMetadata(ctx, run, name) + if err != nil { + return NetworkDetail{}, err + } + + if driver == "" { + driver = strings.TrimSpace(row.Driver) + } + if driver == "" { + driver = row.NativeDriver + } + + scope := strings.TrimSpace(row.Scope) + if scope == "" { + scope = defaultNetworkScope() + } + + subnet := firstSubnet(row) + gateway := firstGateway(row) + if subnet == "" { + subnet = row.NativeSubnet + } + if gateway == "" { + gateway = row.NativeGateway + } + + options := make(map[string]string, len(row.Options)+len(row.NativeOptions)) + for key, value := range row.Options { + options[key] = value + } + for key, value := range row.NativeOptions { + options[key] = value + } + + return NetworkDetail{ + ID: shortNetworkID(row.ID), + Name: row.Name, + Driver: driver, + Scope: scope, + Subnet: subnet, + Gateway: gateway, + Created: humanizeNetworkCreated(row.Created), + Options: options, + }, nil +} + +func removeNetwork(ctx context.Context, run commandRunner, name string) error { + if isPseudoNetwork(name) { + return fmt.Errorf("built-in network %q cannot be removed", name) + } + + _, err := run(ctx, "nerdctl", "network", "rm", name) + if err != nil { + _, err = run(ctx, "docker", "network", "rm", name) + } + return err +} + +type enrichedNetworkInspectRow struct { + networkInspectRow + NativeDriver string + NativeSubnet string + NativeGateway string + NativeOptions map[string]string +} + +func inspectNetworkMetadata(ctx context.Context, run commandRunner, name string) (enrichedNetworkInspectRow, error) { + row := enrichedNetworkInspectRow{ + NativeOptions: make(map[string]string), + } + + args := []string{"network", "inspect", name} + output, err := run(ctx, "nerdctl", args...) + if err != nil { + output, err = run(ctx, "docker", args...) + if err != nil { + return row, err + } + } + + parsed, err := decodeInspectDocuments[networkInspectRow](output) + if err != nil { + return row, err + } + + if len(parsed) == 0 { + return row, fmt.Errorf("inspect network %s", name) + } + + row.networkInspectRow = parsed[0] + if row.Name == "" { + row.Name = name + } + + nativeOutput, nativeErr := run(ctx, "nerdctl", "network", "inspect", "--mode=native", name) + if nativeErr == nil { + applyNativeNetworkInspect(&row, nativeOutput) + } + + return row, nil +} + +func networkInspectMetadataBatch(ctx context.Context, run commandRunner, names []string) (map[string]enrichedNetworkInspectRow, error) { + rows := make(map[string]enrichedNetworkInspectRow, len(names)) + for _, name := range names { + if isPseudoNetwork(name) { + continue + } + + row, err := inspectNetworkMetadata(ctx, run, name) + if err != nil { + slog.Warn("failed to inspect network metadata", "network", name, "error", err) + continue + } + + rows[name] = row + } + + return rows, nil +} + +func networkDriverFromList(ctx context.Context, run commandRunner, name string) (string, error) { + networks, err := queryNerdctlNetworks(ctx, run) + if err != nil { + return "", err + } + + for _, network := range networks { + if network.Name == name { + return network.Driver, nil + } + } + + dockerNetworks, err := queryDockerNetworks(ctx, run) + if err != nil { + return "", fmt.Errorf("network %s not found", name) + } + + for _, network := range dockerNetworks { + if network.Name == name { + return network.Driver, nil + } + } + + return "", fmt.Errorf("network %s not found", name) +} + +func applyNativeNetworkInspect(row *enrichedNetworkInspectRow, output []byte) { + parsed, err := decodeInspectDocuments[nativeNetworkInspect](output) + if err != nil || len(parsed) == 0 { + return + } + + native := parsed[0] + for _, plugin := range native.CNI.Plugins { + pluginType := strings.TrimSpace(plugin.Type) + if row.NativeDriver == "" && pluginType != "" && pluginType != "portmap" && pluginType != "firewall" && pluginType != "tuning" { + row.NativeDriver = pluginType + } + + for _, rangeGroup := range plugin.IPAM.Ranges { + for _, ipamRanges := range rangeGroup { + for _, ipamRange := range ipamRanges { + if row.NativeSubnet == "" && strings.TrimSpace(ipamRange.Subnet) != "" { + row.NativeSubnet = strings.TrimSpace(ipamRange.Subnet) + } + if row.NativeGateway == "" && strings.TrimSpace(ipamRange.Gateway) != "" { + row.NativeGateway = strings.TrimSpace(ipamRange.Gateway) + } + } + } + } + + if bridge := strings.TrimSpace(plugin.Bridge); bridge != "" { + row.NativeOptions["com.docker.network.bridge.name"] = bridge + } + if plugin.MTU > 0 { + row.NativeOptions["com.docker.network.driver.mtu"] = fmt.Sprintf("%d", plugin.MTU) + } + if plugin.IPMasq { + row.NativeOptions["com.docker.network.bridge.enable_ip_masquerade"] = "true" + } + if plugin.HairpinMode { + row.NativeOptions["com.docker.network.bridge.enable_hairpin_mode"] = "true" + } + if plugin.IsGateway { + row.NativeOptions["com.docker.network.bridge.enable_icc"] = "true" + } + } + + if row.Name == "bridge" { + row.NativeOptions["com.docker.network.bridge.default_bridge"] = "true" + } +} + +func ParseNetworkLines(output []byte) ([]Network, error) { + networks := make([]Network, 0) + scanner := bufio.NewScanner(bytes.NewReader(output)) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var row networkLine + if err := json.Unmarshal([]byte(line), &row); err != nil { + continue + } + + if row.Name == "" || isPseudoNetwork(row.Name) { + continue + } + + networks = append(networks, Network{ + ID: shortNetworkID(row.ID), + Name: row.Name, + Driver: row.Driver, + Scope: defaultNetworkScope(), + }) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + sort.Slice(networks, func(i, j int) bool { + return networks[i].Name < networks[j].Name + }) + + return networks, nil +} + +func firstSubnet(row enrichedNetworkInspectRow) string { + for _, config := range row.IPAM.Config { + subnet := strings.TrimSpace(config.Subnet) + if subnet != "" { + return subnet + } + } + + return "" +} + +func firstGateway(row enrichedNetworkInspectRow) string { + for _, config := range row.IPAM.Config { + gateway := strings.TrimSpace(config.Gateway) + if gateway != "" { + return gateway + } + } + + return "" +} + +func defaultNetworkScope() string { + return "local" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + + return "" +} + +func shortNetworkID(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + if len(value) > 12 { + return value[:12] + } + + return value +} + +func humanizeNetworkCreated(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + return humanizeTime(value) +} + +func queryNerdctlNetworks(ctx context.Context, run commandRunner) ([]Network, error) { + output, err := run(ctx, "nerdctl", "network", "ls", "--format", "{{json .}}") + if err != nil { + return nil, err + } + + return ParseNetworkLines(output) +} + +func queryDockerNetworks(ctx context.Context, run commandRunner) ([]Network, error) { + output, err := run(ctx, "docker", "network", "ls", "--format", "{{json .}}") + if err != nil { + return nil, err + } + + return ParseNetworkLines(output) +} + +func mergeNetworksByName(nerdctlNetworks, dockerNetworks []Network) []Network { + if len(dockerNetworks) == 0 { + return nerdctlNetworks + } + + seen := make(map[string]int, len(nerdctlNetworks)) + for i := range nerdctlNetworks { + seen[nerdctlNetworks[i].Name] = i + } + + result := make([]Network, len(nerdctlNetworks), len(nerdctlNetworks)+len(dockerNetworks)) + copy(result, nerdctlNetworks) + + for _, dn := range dockerNetworks { + if _, exists := seen[dn.Name]; exists { + continue + } + seen[dn.Name] = len(result) + result = append(result, dn) + } + + return result +} diff --git a/backend/internal/runtime/proxy.go b/backend/internal/runtime/proxy.go new file mode 100644 index 0000000..2533ea0 --- /dev/null +++ b/backend/internal/runtime/proxy.go @@ -0,0 +1,78 @@ +package runtime + +import ( + "context" + "fmt" + "strings" +) + +type ProxyConfig struct { + HTTPProxy string + HTTPSProxy string + NoProxy string +} + +func applyProxyInVM(ctx context.Context, run commandRunner, proxy ProxyConfig) error { + httpProxyDQ := shellDoubleQuote(proxy.HTTPProxy) + httpsProxyDQ := shellDoubleQuote(proxy.HTTPSProxy) + noProxyDQ := shellDoubleQuote(proxy.NoProxy) + + httpProxySQ := shellQuote(proxy.HTTPProxy) + httpsProxySQ := shellQuote(proxy.HTTPSProxy) + noProxySQ := shellQuote(proxy.NoProxy) + + script := fmt.Sprintf(`set -eux -o pipefail +sudo mkdir -p /etc/systemd/system/containerd.service.d +sudo tee /etc/systemd/system/containerd.service.d/calf-proxy.conf >/dev/null <<'EOF' +[Service] +Environment="HTTP_PROXY=%s" +Environment="HTTPS_PROXY=%s" +Environment="NO_PROXY=%s" +Environment="http_proxy=%s" +Environment="https_proxy=%s" +Environment="no_proxy=%s" +EOF +sudo tee /etc/profile.d/calf-proxy.sh >/dev/null <<'EOF' +export HTTP_PROXY='%s' +export HTTPS_PROXY='%s' +export NO_PROXY='%s' +export http_proxy='%s' +export https_proxy='%s' +export no_proxy='%s' +EOF +sudo systemctl daemon-reload +if systemctl is-active --quiet containerd; then + sudo systemctl restart containerd +fi +if systemctl is-active --quiet docker; then + sudo systemctl restart docker +fi +`, + httpProxyDQ, httpsProxyDQ, noProxyDQ, httpProxyDQ, httpsProxyDQ, noProxyDQ, + httpProxySQ, httpsProxySQ, noProxySQ, httpProxySQ, httpsProxySQ, noProxySQ, + ) + + _, err := run(ctx, "bash", "-lc", script) + return err +} + +func shellQuote(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + return strings.ReplaceAll(value, "'", "'\\''") +} + +func shellDoubleQuote(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + value = strings.ReplaceAll(value, "\\", "\\\\") + value = strings.ReplaceAll(value, "\"", "\\\"") + value = strings.ReplaceAll(value, "$", "\\$") + return value +} diff --git a/backend/internal/runtime/runtime.go b/backend/internal/runtime/runtime.go index 35934cb..b424d37 100644 --- a/backend/internal/runtime/runtime.go +++ b/backend/internal/runtime/runtime.go @@ -87,6 +87,26 @@ type VolumeContainerUsage struct { Target string `json:"target"` } +type Network struct { + ID string `json:"id"` + Name string `json:"name"` + Driver string `json:"driver"` + Scope string `json:"scope"` + Subnet string `json:"subnet"` + Created string `json:"created"` +} + +type NetworkDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Driver string `json:"driver"` + Scope string `json:"scope"` + Subnet string `json:"subnet"` + Gateway string `json:"gateway"` + Created string `json:"created"` + Options map[string]string `json:"options"` +} + type Runtime interface { Start(ctx context.Context) error Stop(ctx context.Context) error @@ -123,12 +143,16 @@ type Runtime interface { RegistryStatus(ctx context.Context) (RegistryStatus, error) RegistryLogin(ctx context.Context, server, username, password string) error RegistryLogout(ctx context.Context, server string) error + ListNetworks(ctx context.Context) ([]Network, error) + InspectNetwork(ctx context.Context, name string) (NetworkDetail, error) + RemoveNetwork(ctx context.Context, name string) error + ApplyProxy(ctx context.Context, proxy ProxyConfig) error } -func New(vmName string, dockerSocket string, cpus int, memoryGB int, memorySwapGB int, diskGB int, apiListenPort int) Runtime { +func New(vmName string, dockerSocket string, cpus int, memoryGB int, memorySwapGB int, diskGB int, apiListenPort int, proxy ProxyConfig) Runtime { if goruntime.GOOS == "linux" { - return NewNative(vmName, dockerSocket, cpus, memoryGB, memorySwapGB, diskGB) + return NewNative(vmName, dockerSocket, cpus, memoryGB, memorySwapGB, diskGB, proxy) } - return NewLima(vmName, dockerSocket, cpus, memoryGB, memorySwapGB, diskGB, apiListenPort) + return NewLima(vmName, dockerSocket, cpus, memoryGB, memorySwapGB, diskGB, apiListenPort, proxy) } diff --git a/backend/test/api/api_test.go b/backend/test/api/api_test.go index f2e66c4..a185901 100644 --- a/backend/test/api/api_test.go +++ b/backend/test/api/api_test.go @@ -372,6 +372,56 @@ func TestVolumesReturnsList(t *testing.T) { } } +func TestNetworksReturnsList(t *testing.T) { + server := newTestServer(t) + defer server.Close() + + response, err := http.Get(server.URL + "/v1/networks") + if err != nil { + t.Fatalf("GET /v1/networks error: %v", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", response.StatusCode) + } + + var networks []map[string]any + if err := json.NewDecoder(response.Body).Decode(&networks); err != nil { + t.Fatalf("Decode() error: %v", err) + } + + if len(networks) != 1 { + t.Fatalf("expected 1 network, got %d", len(networks)) + } +} + +func TestNetworkDetailReturnsMetadata(t *testing.T) { + server := newTestServer(t) + defer server.Close() + + response, err := http.Get(server.URL + "/v1/networks/bridge") + if err != nil { + t.Fatalf("GET /v1/networks/bridge error: %v", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", response.StatusCode) + } + + var payload map[string]any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("Decode() error: %v", err) + } + + for _, key := range []string{"name", "driver", "scope", "subnet", "gateway", "created"} { + if _, ok := payload[key]; !ok { + t.Fatalf("expected %q in response", key) + } + } +} + func TestVolumeDetailReturnsMetadata(t *testing.T) { server := newTestServer(t) defer server.Close() diff --git a/backend/test/runtime/network_test.go b/backend/test/runtime/network_test.go new file mode 100644 index 0000000..314fbc3 --- /dev/null +++ b/backend/test/runtime/network_test.go @@ -0,0 +1,43 @@ +package runtime_test + +import ( + "testing" + + "github.com/enegalan/calf/backend/internal/runtime" +) + +func TestParseNetworkLines(t *testing.T) { + output := []byte(`{"ID":"9d1ce4c80488","Name":"bridge","Driver":"bridge"} +{"ID":"a1b2c3d4e5f6","Name":"p2p-lan_local_dev","Driver":"bridge"} +{"Name":"host","Driver":"host"} +{"Name":"none","Driver":"null"}`) + + networks, err := runtime.ParseNetworkLines(output) + if err != nil { + t.Fatalf("ParseNetworkLines() error: %v", err) + } + + if len(networks) != 2 { + t.Fatalf("expected 2 networks, got %d", len(networks)) + } + + if networks[0].Name != "bridge" || networks[0].ID != "9d1ce4c80488" { + t.Fatalf("unexpected first network: %+v", networks[0]) + } + + if networks[0].Driver != "bridge" || networks[0].Scope != "local" { + t.Fatalf("expected driver bridge and scope local, got %+v", networks[0]) + } +} + +func TestIsPseudoNetwork(t *testing.T) { + for _, name := range []string{"host", "none", "null", "HOST", " None "} { + if !runtime.IsPseudoNetwork(name) { + t.Fatalf("expected %q to be pseudo network", name) + } + } + + if runtime.IsPseudoNetwork("bridge") { + t.Fatal("expected bridge not to be pseudo network") + } +} diff --git a/backend/version/version.go b/backend/version/version.go index c1635d2..6cc8872 100644 --- a/backend/version/version.go +++ b/backend/version/version.go @@ -1,3 +1,3 @@ package version -const Version = "0.4.0" +const Version = "0.6.0" diff --git a/ui/lib/api/client.dart b/ui/lib/api/client.dart index e011fe5..9f86701 100644 --- a/ui/lib/api/client.dart +++ b/ui/lib/api/client.dart @@ -391,6 +391,78 @@ class VolumeItem { } } +class NetworkItem { + const NetworkItem({ + required this.id, + required this.name, + required this.driver, + required this.scope, + this.subnet = '', + this.created = '', + }); + + final String id; + final String name; + final String driver; + final String scope; + final String subnet; + final String created; + + factory NetworkItem.fromJson(Map json) { + return NetworkItem( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + driver: json['driver'] as String? ?? '', + scope: json['scope'] as String? ?? '', + subnet: json['subnet'] as String? ?? '', + created: json['created'] as String? ?? '', + ); + } +} + +class NetworkDetail { + const NetworkDetail({ + required this.id, + required this.name, + required this.driver, + required this.scope, + required this.subnet, + required this.gateway, + required this.created, + this.options = const {}, + }); + + final String id; + final String name; + final String driver; + final String scope; + final String subnet; + final String gateway; + final String created; + final Map options; + + factory NetworkDetail.fromJson(Map json) { + final rawOptions = json['options']; + final options = {}; + if (rawOptions is Map) { + for (final entry in rawOptions.entries) { + options['${entry.key}'] = '${entry.value}'; + } + } + + return NetworkDetail( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + driver: json['driver'] as String? ?? '', + scope: json['scope'] as String? ?? '', + subnet: json['subnet'] as String? ?? '', + gateway: json['gateway'] as String? ?? '', + created: json['created'] as String? ?? '', + options: options, + ); + } +} + class VolumeDetail { const VolumeDetail({ required this.name, @@ -1102,6 +1174,8 @@ abstract class CalfClient implements StatusClient { Future> fetchImages(); Future> fetchImageLayers(String reference); Future> fetchVolumes(); + Future> fetchNetworks(); + Future fetchNetworkDetail(String name); Future fetchVolumeDetail(String name); Future> fetchVolumeFiles(String name, {String path = '/'}); Future> fetchVolumeContainers(String name); @@ -1159,6 +1233,7 @@ abstract class CalfClient implements StatusClient { Future createVolume(String name); Future cloneVolume(String source, String name); Future removeVolume(String name); + Future removeNetwork(String name); Future runBuild({required String context, required String tag, String dockerfile = ''}); Stream streamContainerLogs(String id); Uri containerLogsWebSocketUri(String id); @@ -1222,6 +1297,18 @@ class ApiClient implements CalfClient { return _decodeList(response, VolumeItem.fromJson); } + @override + Future> fetchNetworks() async { + final response = await httpClient.get(Uri.parse('$baseUrl/v1/networks')).timeout(timeout); + return _decodeList(response, NetworkItem.fromJson); + } + + @override + Future fetchNetworkDetail(String name) async { + final json = await _getJson('/v1/networks/${Uri.encodeComponent(name)}'); + return NetworkDetail.fromJson(json); + } + @override Future fetchVolumeDetail(String name) async { final json = await _getJson( @@ -1742,6 +1829,11 @@ class ApiClient implements CalfClient { await _delete('/v1/volumes/$name'); } + @override + Future removeNetwork(String name) async { + await _delete('/v1/networks/${Uri.encodeComponent(name)}'); + } + @override Future runBuild({required String context, required String tag, String dockerfile = ''}) async { final response = await httpClient @@ -1992,6 +2084,9 @@ class Config { this.dockerContextActive = false, this.dockerContextName = '', this.dockerCliAvailable = false, + this.httpProxy = '', + this.httpsProxy = '', + this.noProxy = '', }); final int pollIntervalMs; @@ -2004,12 +2099,18 @@ class Config { final bool dockerContextActive; final String dockerContextName; final bool dockerCliAvailable; + final String httpProxy; + final String httpsProxy; + final String noProxy; Map toJson() => { 'cpus': cpus, 'memory_gb': memoryGB, 'memory_swap_gb': memorySwapGB, 'docker_context_managed': dockerContextManaged, + 'http_proxy': httpProxy, + 'https_proxy': httpsProxy, + 'no_proxy': noProxy, }; factory Config.fromJson(Map json) { @@ -2024,6 +2125,9 @@ class Config { dockerContextActive: json['docker_context_active'] as bool? ?? false, dockerContextName: json['docker_context_name'] as String? ?? '', dockerCliAvailable: json['docker_cli_available'] as bool? ?? false, + httpProxy: json['http_proxy'] as String? ?? '', + httpsProxy: json['https_proxy'] as String? ?? '', + noProxy: json['no_proxy'] as String? ?? '', ); } @@ -2038,6 +2142,9 @@ class Config { bool? dockerContextActive, String? dockerContextName, bool? dockerCliAvailable, + String? httpProxy, + String? httpsProxy, + String? noProxy, }) { return Config( pollIntervalMs: pollIntervalMs ?? this.pollIntervalMs, @@ -2050,6 +2157,9 @@ class Config { dockerContextActive: dockerContextActive ?? this.dockerContextActive, dockerContextName: dockerContextName ?? this.dockerContextName, dockerCliAvailable: dockerCliAvailable ?? this.dockerCliAvailable, + httpProxy: httpProxy ?? this.httpProxy, + httpsProxy: httpsProxy ?? this.httpsProxy, + noProxy: noProxy ?? this.noProxy, ); } } diff --git a/ui/lib/app_shell.dart b/ui/lib/app_shell.dart index 26480c0..77b3f75 100644 --- a/ui/lib/app_shell.dart +++ b/ui/lib/app_shell.dart @@ -4,6 +4,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:ui/api/client.dart'; import 'package:ui/screens/containers_screen.dart'; +import 'package:ui/screens/networks_screen.dart'; import 'package:ui/screens/resources_screen.dart'; import 'package:ui/widgets/app_top_bar.dart'; import 'package:ui/widgets/calf_button.dart'; @@ -115,6 +116,7 @@ class _AppShellState extends State { (label: 'Containers', icon: LucideIcons.box), (label: 'Images', icon: LucideIcons.layers), (label: 'Volumes', icon: LucideIcons.hardDrive), + (label: 'Networks', icon: LucideIcons.network), (label: 'Builds', icon: LucideIcons.wrench), ]; @@ -173,6 +175,7 @@ class _AppShellState extends State { 0 => ContainersScreen(apiClient: widget.apiClient), 1 => ImagesScreen(apiClient: widget.apiClient), 2 => VolumesScreen(apiClient: widget.apiClient), + 3 => NetworksScreen(apiClient: widget.apiClient), _ => BuildsScreen(apiClient: widget.apiClient), }, ), @@ -252,6 +255,12 @@ class _SettingsScreenState extends State { double _draftCpus = 4; double _draftMemory = 4; double _draftSwap = 1; + final _httpProxyController = TextEditingController(); + final _httpsProxyController = TextEditingController(); + final _noProxyInputController = TextEditingController(); + List _noProxyEntries = []; + String? _httpProxyError; + String? _httpsProxyError; bool _migrating = false; MigrationStatus? _migrationStatus; bool _dockerContextManaged = true; @@ -260,7 +269,18 @@ class _SettingsScreenState extends State { bool get _dirty => _config != null && (_draftCpus.toInt() != _config!.cpus || _draftMemory.toInt() != _config!.memoryGB || - _draftSwap.toInt() != _config!.memorySwapGB); + _draftSwap.toInt() != _config!.memorySwapGB || + _httpProxyController.text.trim() != _config!.httpProxy || + _httpsProxyController.text.trim() != _config!.httpsProxy || + _noProxyEntries.join(',') != _config!.noProxy); + + @override + void dispose() { + _httpProxyController.dispose(); + _httpsProxyController.dispose(); + _noProxyInputController.dispose(); + super.dispose(); + } @override void initState() { @@ -282,6 +302,15 @@ class _SettingsScreenState extends State { _draftCpus = config.cpus.toDouble(); _draftMemory = config.memoryGB.toDouble(); _draftSwap = config.memorySwapGB.toDouble(); + _httpProxyController.text = config.httpProxy; + _httpsProxyController.text = config.httpsProxy; + _noProxyEntries = config.noProxy + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + _httpProxyError = null; + _httpsProxyError = null; _dockerContextManaged = config.dockerContextManaged; _configLoading = false; }); @@ -306,6 +335,9 @@ class _SettingsScreenState extends State { cpus: _draftCpus.toInt(), memoryGB: _draftMemory.toInt(), memorySwapGB: _draftSwap.toInt(), + httpProxy: _httpProxyController.text.trim(), + httpsProxy: _httpsProxyController.text.trim(), + noProxy: _noProxyEntries.join(','), ), ); if (!mounted) return; @@ -314,6 +346,15 @@ class _SettingsScreenState extends State { _draftCpus = updated.cpus.toDouble(); _draftMemory = updated.memoryGB.toDouble(); _draftSwap = updated.memorySwapGB.toDouble(); + _httpProxyController.text = updated.httpProxy; + _httpsProxyController.text = updated.httpsProxy; + _noProxyEntries = updated.noProxy + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + _httpProxyError = null; + _httpsProxyError = null; _saving = false; }); } catch (error) { @@ -499,6 +540,56 @@ class _SettingsScreenState extends State { ], ], const SizedBox(height: 24), + ShadCard( + padding: const EdgeInsets.all(20), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Proxy'), + if (_config != null && + (_config!.httpProxy.isNotEmpty || + _config!.httpsProxy.isNotEmpty || + _config!.noProxy.isNotEmpty)) + const Padding( + padding: EdgeInsets.only(left: 8), + child: ShadBadge.secondary( + child: Text('Configured'), + ), + ), + ], + ), + description: const Text( + 'HTTP and HTTPS proxy settings for image pulls inside the VM.', + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + _proxyField( + label: 'HTTP proxy', + controller: _httpProxyController, + placeholder: 'http://proxy.example.com:8080', + icon: LucideIcons.globe, + theme: theme, + error: _httpProxyError, + onChanged: _validateHttpProxy, + ), + const SizedBox(height: 12), + _proxyField( + label: 'HTTPS proxy', + controller: _httpsProxyController, + placeholder: 'http://proxy.example.com:8080', + icon: LucideIcons.lock, + theme: theme, + error: _httpsProxyError, + onChanged: _validateHttpsProxy, + ), + const SizedBox(height: 12), + _noProxySection(theme), + ], + ), + ), + const SizedBox(height: 24), _sectionHeader('System', theme), const SizedBox(height: 12), if (_configLoading) @@ -537,8 +628,10 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 24), CalfButton( - onPressed: _dirty && !_saving ? applyConfig : null, - enabled: _dirty, + onPressed: _dirty && !_saving && _httpProxyError == null && _httpsProxyError == null + ? applyConfig + : null, + enabled: _dirty && _httpProxyError == null && _httpsProxyError == null, child: Text(_saving ? 'Saving...' : 'Apply'), ), ], @@ -617,4 +710,189 @@ class _SettingsScreenState extends State { ], ); } + + void _validateHttpProxy(String value) { + setState(() => _httpProxyError = _validateProxyUrl(value, ['http'])); + } + + void _validateHttpsProxy(String value) { + setState(() => _httpsProxyError = _validateProxyUrl(value, ['http', 'https'])); + } + + String? _validateProxyUrl(String value, List allowedSchemes) { + final v = value.trim(); + if (v.isEmpty) return null; + final hasScheme = allowedSchemes.any((s) => v.startsWith('$s://')); + if (!hasScheme) { + return allowedSchemes.length == 1 + ? 'Must start with ${allowedSchemes.first}://' + : 'Must start with ${allowedSchemes.join(' or ')}://'; + } + final uri = Uri.tryParse(v); + if (uri == null || !uri.hasScheme || !uri.hasAuthority) { + return 'Invalid URL format'; + } + return null; + } + + Widget _noProxySection(ShadThemeData theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('No proxy', style: theme.textTheme.small.copyWith(color: theme.colorScheme.mutedForeground)), + const SizedBox(height: 8), + if (_noProxyEntries.isNotEmpty) ...[ + Wrap( + spacing: 6, + runSpacing: 6, + children: _noProxyEntries.map((entry) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.muted, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(entry, style: theme.textTheme.small), + const SizedBox(width: 6), + GestureDetector( + onTap: () { + setState(() => _noProxyEntries.remove(entry)); + }, + child: Icon(LucideIcons.x, size: 12, color: theme.colorScheme.mutedForeground), + ), + ], + ), + ); + }).toList(), + ), + const SizedBox(height: 8), + ], + Row( + children: [ + Expanded( + child: ShadInput( + controller: _noProxyInputController, + placeholder: const Text('localhost'), + leading: Icon(LucideIcons.ban, size: 16, color: theme.colorScheme.mutedForeground), + onChanged: (_) => setState(() {}), + onSubmitted: (value) { + _addNoProxyEntry(value, theme); + }, + ), + ), + const SizedBox(width: 8), + CalfButton.outline( + padding: const EdgeInsets.symmetric(horizontal: 12), + onPressed: _noProxyInputController.text.trim().isEmpty + ? null + : () => _addNoProxyEntry(_noProxyInputController.text, theme), + child: const Text('Add'), + ), + ], + ), + if (_noProxyInputController.text.trim().isNotEmpty && + !_isValidNoProxyEntry(_noProxyInputController.text.trim())) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + 'Must be a valid hostname or IP address', + style: theme.textTheme.muted.copyWith(fontSize: 12), + ), + ), + ], + ); + } + + void _addNoProxyEntry(String rawValue, ShadThemeData theme) { + final value = rawValue.trim(); + if (value.isEmpty || _noProxyEntries.contains(value)) return; + if (!_isValidNoProxyEntry(value)) return; + setState(() { + _noProxyEntries.add(value); + _noProxyInputController.clear(); + }); + } + + bool _isValidNoProxyEntry(String entry) { + if (entry.isEmpty) return false; + if (entry.contains('/')) return false; + final host = entry.startsWith('.') ? entry.substring(1) : entry; + if (_isIpAddress(host)) return true; + final colonIdx = host.lastIndexOf(':'); + if (colonIdx > 0) { + final port = host.substring(colonIdx + 1); + if (RegExp(r'^\d+$').hasMatch(port)) { + return _isValidHostname(host.substring(0, colonIdx)); + } + } + return _isValidHostname(host); + } + + bool _isIpAddress(String host) { + return RegExp(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$').hasMatch(host); + } + + bool _isValidHostname(String host) { + if (host.isEmpty || host.length > 253) return false; + final parts = host.split('.'); + for (final part in parts) { + if (part.isEmpty || part.length > 63) return false; + if (part.startsWith('-') || part.endsWith('-')) return false; + if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(part)) return false; + } + return true; + } + + Widget _proxyField({ + required String label, + required TextEditingController controller, + required String placeholder, + required IconData icon, + required ShadThemeData theme, + String? error, + required ValueChanged onChanged, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small.copyWith(color: theme.colorScheme.mutedForeground)), + const SizedBox(height: 8), + ShadInput( + controller: controller, + placeholder: Text(placeholder), + leading: Icon(icon, size: 16, color: theme.colorScheme.mutedForeground), + trailing: controller.text.isNotEmpty + ? GestureDetector( + onTap: () { + controller.clear(); + onChanged(''); + }, + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon(LucideIcons.x, size: 14, color: theme.colorScheme.mutedForeground), + ), + ) + : null, + onChanged: (value) { + setState(() {}); + onChanged(value); + }, + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + error, + style: theme.textTheme.muted.copyWith( + fontSize: 12, + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ); + } } diff --git a/ui/lib/screens/networks_screen.dart b/ui/lib/screens/networks_screen.dart new file mode 100644 index 0000000..d8460f7 --- /dev/null +++ b/ui/lib/screens/networks_screen.dart @@ -0,0 +1,474 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:ui/api/client.dart'; +import 'package:ui/widgets/calf_button.dart'; +import 'package:ui/widgets/hover_list_row.dart'; + +class NetworksScreen extends StatefulWidget { + const NetworksScreen({super.key, required this.apiClient}); + + final CalfClient apiClient; + + @override + State createState() => _NetworksScreenState(); +} + +class _NetworksScreenState extends State { + List _networks = []; + RuntimeStatus? _runtime; + String? _error; + bool _loading = true; + Timer? _timer; + int _pollIntervalMs = 3000; + final _searchController = TextEditingController(); + String _searchQuery = ''; + String? _selectedNetwork; + + @override + void initState() { + super.initState(); + _loadNetworks(); + _loadConfig(); + _searchController.addListener(() { + setState(() => _searchQuery = _searchController.text.trim().toLowerCase()); + }); + } + + @override + void dispose() { + _timer?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + Future _loadConfig() async { + try { + final config = await widget.apiClient.fetchConfig(); + if (!mounted) { + return; + } + _pollIntervalMs = config.pollIntervalMs; + _timer = Timer.periodic(Duration(milliseconds: _pollIntervalMs), (_) => _loadNetworks(silent: true)); + } catch (_) { + if (!mounted) { + return; + } + _timer = Timer.periodic(Duration(milliseconds: _pollIntervalMs), (_) => _loadNetworks(silent: true)); + } + } + + Future _loadNetworks({bool silent = false}) async { + if (!silent) { + setState(() { + _loading = true; + _error = null; + }); + } + + try { + final status = await widget.apiClient.fetchStatus(); + final networks = List.from(await widget.apiClient.fetchNetworks()) + ..sort((a, b) => a.name.compareTo(b.name)); + if (!mounted) { + return; + } + setState(() { + _runtime = status.runtime; + _networks = networks; + _loading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + if (!silent) { + setState(() { + _error = error.toString(); + _loading = false; + }); + } + } + } + + void _openNetwork(NetworkItem network) { + setState(() => _selectedNetwork = network.name); + } + + void _closeNetwork() { + setState(() => _selectedNetwork = null); + } + + List _filteredNetworks() { + if (_searchQuery.isEmpty) { + return _networks; + } + + return _networks + .where((network) => + network.name.toLowerCase().contains(_searchQuery) || + network.subnet.toLowerCase().contains(_searchQuery) || + network.driver.toLowerCase().contains(_searchQuery)) + .toList(); + } + + Future _removeNetwork(NetworkItem network) async { + try { + await widget.apiClient.removeNetwork(network.name); + if (_selectedNetwork == network.name) { + _closeNetwork(); + } + await _loadNetworks(); + } catch (error) { + if (!mounted) { + return; + } + setState(() => _error = error.toString()); + } + } + + @override + Widget build(BuildContext context) { + if (_selectedNetwork != null) { + return NetworkDetailView( + networkName: _selectedNetwork!, + apiClient: widget.apiClient, + onBack: _closeNetwork, + onRemoved: _loadNetworks, + ); + } + + final theme = ShadTheme.of(context); + final filtered = _filteredNetworks(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Networks', style: theme.textTheme.h3), + const SizedBox(height: 16), + ShadInput( + controller: _searchController, + placeholder: const Text('Search'), + ), + const SizedBox(height: 16), + if (_loading) + Text('Loading...', style: theme.textTheme.large) + else if (_error != null) + Text( + _error!.replaceAll(r'\n', ' ').trim(), + style: theme.textTheme.large.copyWith(color: theme.colorScheme.destructive), + ) + else if (filtered.isEmpty) + Text( + _searchQuery.isNotEmpty + ? 'No networks match "$_searchQuery".' + : _runtime?.state == 'stopped' + ? 'No networks. Runtime is stopped.' + : 'No networks.', + style: theme.textTheme.muted, + ) + else + Expanded( + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final network = filtered[index]; + + return HoverListRow( + theme: theme, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + onTap: () => _openNetwork(network), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(network.name, style: theme.textTheme.large), + if (network.subnet.isNotEmpty) + Text(network.subnet, style: theme.textTheme.muted), + ], + ), + ), + CalfButton.ghost( + padding: const EdgeInsets.symmetric(horizontal: 10), + onPressed: () => _removeNetwork(network), + child: Icon( + LucideIcons.trash2, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ); + } +} + +class NetworkDetailView extends StatefulWidget { + const NetworkDetailView({ + super.key, + required this.networkName, + required this.apiClient, + required this.onBack, + required this.onRemoved, + }); + + final String networkName; + final CalfClient apiClient; + final VoidCallback onBack; + final Future Function() onRemoved; + + @override + State createState() => _NetworkDetailViewState(); +} + +class _NetworkDetailViewState extends State { + NetworkDetail? _detail; + String? _error; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadDetail(); + } + + Future _loadDetail() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + final detail = await widget.apiClient.fetchNetworkDetail(widget.networkName); + if (!mounted) { + return; + } + setState(() { + _detail = detail; + _loading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _error = error.toString(); + _loading = false; + }); + } + } + + Future _removeNetwork() async { + try { + await widget.apiClient.removeNetwork(widget.networkName); + if (!mounted) { + return; + } + await widget.onRemoved(); + widget.onBack(); + } catch (error) { + if (!mounted) { + return; + } + setState(() => _error = error.toString()); + } + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CalfButton.ghost( + onPressed: widget.onBack, + child: Icon(LucideIcons.chevronLeft, size: 18, color: theme.colorScheme.foreground), + ), + const SizedBox(width: 4), + Text('Networks', style: theme.textTheme.muted), + Text(' / ', style: theme.textTheme.muted), + Expanded( + child: Text( + widget.networkName, + style: theme.textTheme.muted, + overflow: TextOverflow.ellipsis, + ), + ), + CalfButton.outline( + onPressed: _removeNetwork, + child: const Text('Remove'), + ), + ], + ), + const SizedBox(height: 24), + if (_loading) + Text('Loading...', style: theme.textTheme.large) + else if (_error != null) + Text( + _error!.replaceAll(r'\n', ' ').trim(), + style: theme.textTheme.large.copyWith(color: theme.colorScheme.destructive), + ) + else if (_detail != null) + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoCard( + theme: theme, + rows: [ + _InfoRow(label: 'Name', value: _detail!.name), + _InfoRow(label: 'ID', value: _detail!.id), + _InfoRow(label: 'Created', value: _displayValue(_detail!.created)), + _InfoRow(label: 'Subnet', value: _displayValue(_detail!.subnet)), + _InfoRow(label: 'Gateway', value: _displayValue(_detail!.gateway)), + ], + ), + const SizedBox(height: 16), + _InfoCard( + theme: theme, + rows: [ + _InfoRow(label: 'Driver', value: _displayValue(_detail!.driver)), + _InfoRow(label: 'Scope', value: _displayValue(_detail!.scope)), + ], + ), + if (_detail!.options.isNotEmpty) ...[ + const SizedBox(height: 24), + Text('Options', style: theme.textTheme.h4), + const SizedBox(height: 12), + _OptionsTable(theme: theme, options: _detail!.options), + ], + ], + ), + ), + ), + ], + ); + } + + String _displayValue(String value) { + return value.isEmpty ? '—' : value; + } +} + +class _InfoCard extends StatelessWidget { + const _InfoCard({required this.theme, required this.rows}); + + final ShadThemeData theme; + final List<_InfoRow> rows; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.border), + borderRadius: BorderRadius.circular(8), + color: theme.colorScheme.card, + ), + child: Column( + children: [ + for (var index = 0; index < rows.length; index++) ...[ + if (index > 0) const SizedBox(height: 12), + Row( + children: [ + Expanded(child: Text(rows[index].label, style: theme.textTheme.large)), + Expanded( + child: Text( + rows[index].value, + textAlign: TextAlign.end, + style: theme.textTheme.muted, + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _InfoRow { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String value; +} + +class _OptionsTable extends StatelessWidget { + const _OptionsTable({required this.theme, required this.options}); + + final ShadThemeData theme; + final Map options; + + @override + Widget build(BuildContext context) { + final entries = options.entries.toList()..sort((a, b) => a.key.compareTo(b.key)); + + return Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.border), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: theme.colorScheme.muted, + borderRadius: const BorderRadius.vertical(top: Radius.circular(7)), + ), + child: Row( + children: [ + Expanded(child: Text('Key', style: theme.textTheme.small)), + Expanded(child: Text('Value', style: theme.textTheme.small)), + ], + ), + ), + for (var index = 0; index < entries.length; index++) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + border: index < entries.length - 1 + ? Border(bottom: BorderSide(color: theme.colorScheme.border)) + : null, + ), + child: Row( + children: [ + Expanded( + child: Text( + entries[index].key, + style: theme.textTheme.muted, + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + child: Text( + entries[index].value, + style: theme.textTheme.small, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/ui/lib/screens/resources_screen.dart b/ui/lib/screens/resources_screen.dart index 265a5de..4322541 100644 --- a/ui/lib/screens/resources_screen.dart +++ b/ui/lib/screens/resources_screen.dart @@ -1012,7 +1012,7 @@ class _BuildsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(build.tag, style: theme.textTheme.large), - Text('${build.context} · ${build.status}', style: theme.textTheme.muted), + Text(build.context.isNotEmpty ? '${build.context} · ${build.status}' : build.status, style: theme.textTheme.muted), Text( [ if (build.durationMs > 0) _formatBuildDuration(build.durationMs), diff --git a/ui/pubspec.yaml b/ui/pubspec.yaml index 263638e..0c35917 100644 --- a/ui/pubspec.yaml +++ b/ui/pubspec.yaml @@ -1,7 +1,7 @@ name: ui description: "Calf UI App." publish_to: 'none' -version: 0.4.0+1 +version: 0.6.0+1 environment: sdk: ^3.12.2 diff --git a/ui/test/widget_test.dart b/ui/test/widget_test.dart index fc6363b..877ab05 100644 --- a/ui/test/widget_test.dart +++ b/ui/test/widget_test.dart @@ -43,6 +43,28 @@ class FakeCalfClient implements CalfClient { VolumeItem(name: 'calf-data', driver: 'local', inUse: true, size: '88 B', created: '9 months ago'), ]; + @override + Future> fetchNetworks() async => const [ + NetworkItem( + id: '9d1ce4c80488', + name: 'bridge', + driver: 'bridge', + scope: 'local', + subnet: '192.168.215.0/24', + ), + ]; + + @override + Future fetchNetworkDetail(String name) async => NetworkDetail( + id: '9d1ce4c80488', + name: name, + driver: 'bridge', + scope: 'local', + subnet: '192.168.215.0/24', + gateway: '192.168.215.1', + created: '9 months ago', + ); + @override Future fetchVolumeDetail(String name) async => VolumeDetail( name: name, @@ -234,6 +256,9 @@ class FakeCalfClient implements CalfClient { @override Future removeVolume(String name) async {} + @override + Future removeNetwork(String name) async {} + @override Future runBuild({required String context, required String tag, String dockerfile = ''}) async { return BuildItem( @@ -440,6 +465,20 @@ class _ErrorCalfClient implements CalfClient { @override Future> fetchVolumes() async => []; + @override + Future> fetchNetworks() async => []; + + @override + Future fetchNetworkDetail(String name) async => NetworkDetail( + id: '', + name: name, + driver: 'bridge', + scope: 'local', + subnet: '', + gateway: '', + created: '', + ); + @override Future fetchVolumeDetail(String name) async => VolumeDetail( name: name, @@ -606,6 +645,9 @@ class _ErrorCalfClient implements CalfClient { @override Future removeVolume(String name) async {} + @override + Future removeNetwork(String name) async {} + @override Future runBuild({required String context, required String tag, String dockerfile = ''}) async { return BuildItem(