From f6d4eaa6a7632232feb35590bcb10e9db975447d Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Mon, 27 Apr 2026 08:17:21 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yaml | 2 +- CLAUDE.md | 25 + cmd/feature/feature.go | 2 +- cmd/feature/install.go | 43 +- cmd/feature/list.go | 29 +- cmd/feature/store.go | 48 ++ cmd/info/metrics.go | 100 +-- cmd/info/uptime.go | 30 +- cmd/info/version.go | 2 +- cmd/secrets/secrets.go | 3 +- cmd/secrets/{vault.go => vault/decrypt.go} | 13 +- cmd/secrets/vault/ls.go | 50 ++ cmd/secrets/vault/rotate.go | 72 +++ cmd/secrets/vault/vault.go | 14 + cmd/serve/metrics.go | 680 +-------------------- cmd/show/ip.go | 6 +- cmd/show/path.go | 6 +- cmd/template/list.go | 9 +- go.mod | 4 +- internals/config/defaults.go | 19 +- internals/features/ansible.go | 40 ++ internals/features/parser.go | 20 +- internals/features/parser_test.go | 65 +- internals/features/store.go | 46 ++ internals/metrics/collectors.go | 95 +++ internals/metrics/prometheus.go | 469 ++++++++++++++ internals/metrics/serve.go | 119 ++++ internals/metrics/summary.go | 60 ++ internals/secrets/vault.go | 84 +++ internals/secrets/vault_test.go | 249 ++++++++ internals/styles/format.go | 43 +- internals/styles/list.go | 21 + internals/styles/output.go | 7 +- plan.md | 417 +++++++++++++ renovate.json | 4 + 35 files changed, 1994 insertions(+), 902 deletions(-) create mode 100644 CLAUDE.md create mode 100644 cmd/feature/store.go rename cmd/secrets/{vault.go => vault/decrypt.go} (87%) create mode 100644 cmd/secrets/vault/ls.go create mode 100644 cmd/secrets/vault/rotate.go create mode 100644 cmd/secrets/vault/vault.go create mode 100644 internals/features/ansible.go create mode 100644 internals/features/store.go create mode 100644 internals/metrics/collectors.go create mode 100644 internals/metrics/prometheus.go create mode 100644 internals/metrics/serve.go create mode 100644 internals/metrics/summary.go create mode 100644 plan.md create mode 100644 renovate.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 92dad05..b158cb3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,7 +15,7 @@ jobs: lint: name: 🧹 Lint runs-on: ubuntu-latest - if: github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]' + if: github.actor != 'renovate[bot]' steps: - name: πŸ“₯ Checkout repository diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7e160df --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +# Requirements + +1. **Separation of concerns** + Keep argument/flag parsing at the edges. Commands should delegate to exported functions that contain the business logic (MVC-inspired: parsing β‰  logic). + +2. **Command tree wiring** + The root command registers its direct children (e.g., `rootCmd.AddCommand(log.LogCmd)`), and each child registers its own subcommands. + +3. **Pragmatic structure** + Avoid over-engineering: no DI frameworks and don’t create `internal/*` packages for every command. Place shared logic in importable modules so it’s reusable and testable without duplication. + +4. **Dependency policy** + Prefer native/standard library solutions over third-party packages whenever possible. + +5. **Testing** + For tests, use the `gotest.tools/v3/assert` library instead of `if/fail` conditions. + +6. **Backwards compatibility** + This is not a public library; legacy compatibility isn’t required. + +7. **CLI UX** + Add colorized output to make the CLI more user-friendly. + +8. **Comments** + Do not not add comments unless specifically instructed diff --git a/cmd/feature/feature.go b/cmd/feature/feature.go index 9c980e6..e6f3ce7 100644 --- a/cmd/feature/feature.go +++ b/cmd/feature/feature.go @@ -18,5 +18,5 @@ func init() { "Root directory of additional features", ) - FeatureCmd.AddCommand(installCmd, listCmd, infoCmd) + FeatureCmd.AddCommand(installCmd, listCmd, infoCmd, storeCmd) } diff --git a/cmd/feature/install.go b/cmd/feature/install.go index e074d4f..fa64ffc 100644 --- a/cmd/feature/install.go +++ b/cmd/feature/install.go @@ -2,12 +2,9 @@ package feature import ( "fmt" - "os" - "os/exec" "path/filepath" "slices" "sort" - "strings" "github.com/kloudkit/ws-cli/internals/features" "github.com/kloudkit/ws-cli/internals/styles" @@ -22,13 +19,12 @@ var installCmd = &cobra.Command{ featuresDir, _ := cmd.Flags().GetString("root") featureName := args[0] - availableFeatures, err := features.ListFeatures(featuresDir) - + result, err := features.ListFeatures(featuresDir) if err != nil { return fmt.Errorf("failed to list features: %w", err) } - featureExists := slices.ContainsFunc(availableFeatures, func(f *features.Feature) bool { + featureExists := slices.ContainsFunc(result.Features, func(f *features.Feature) bool { return f.Name == featureName }) @@ -40,31 +36,6 @@ var installCmd = &cobra.Command{ }, } -func runAnsiblePlaybook(featurePath string, vars map[string]any) error { - args := []string{featurePath} - - if len(vars) > 0 { - var extraVars []string - for key, value := range vars { - extraVars = append(extraVars, fmt.Sprintf("%s=%v", key, value)) - } - args = append(args, "--extra-vars", strings.Join(extraVars, " ")) - } - - cmd := exec.Command("ansible-playbook", args...) - cmd.Env = append(os.Environ(), - "ANSIBLE_DISPLAY_OK_HOSTS=0", - "ANSIBLE_DISPLAY_FAILED_STDERR=0", - "ANSIBLE_DISPLAY_SKIPPED_HOSTS=0", - "ANSIBLE_SHOW_CUSTOM_STATS=0", - "ANSIBLE_STDOUT_CALLBACK=community.general.unixy", - ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() -} - func installFeatureByName(cmd *cobra.Command, featureName, featuresDir string) error { fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.Title().Render(fmt.Sprintf("Installing %s", featureName))) @@ -84,14 +55,8 @@ func installFeatureByName(cmd *cobra.Command, featureName, featuresDir string) e featurePath := filepath.Join(featuresDir, featureName+".yaml") - if _, err := features.InfoFeature(featuresDir, featureName); err != nil { - return fmt.Errorf("feature installation failed: %w", err) - } - - if err := runAnsiblePlaybook(featurePath, vars); err != nil { - fmt.Fprintln(cmd.ErrOrStderr()) - styles.PrintError(cmd.ErrOrStderr(), err.Error()) - os.Exit(1) + if err := features.RunPlaybook(featurePath, vars); err != nil { + return fmt.Errorf("installation failed: %w", err) } fmt.Fprintln(cmd.OutOrStdout()) diff --git a/cmd/feature/list.go b/cmd/feature/list.go index bd46ed6..bec2ad7 100644 --- a/cmd/feature/list.go +++ b/cmd/feature/list.go @@ -15,35 +15,28 @@ var listCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { featuresDir, _ := cmd.Flags().GetString("root") - availableFeatures, err := features.ListFeatures(featuresDir) + result, err := features.ListFeatures(featuresDir) if err != nil { return fmt.Errorf("failed to list features: %w", err) } - if len(availableFeatures) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), styles.Warning().Render("⚠ No features found")) - return nil + for _, w := range result.Warnings { + styles.PrintWarning(cmd.OutOrStdout(), w) } - fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.TitleWithCount("Features Available", len(availableFeatures))) - - maxNameLen := 0 - for _, feature := range availableFeatures { - if len(feature.Name) > maxNameLen { - maxNameLen = len(feature.Name) - } + if len(result.Features) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), styles.Warning().Render("⚠ No features found")) + return nil } - var featureItems []any - for _, feature := range availableFeatures { - item := styles.Key().Width(maxNameLen).Render(feature.Name) + - styles.Muted().Render(" β€” ") + - styles.Value().Render(feature.Description) + fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.TitleWithCount("Features Available", len(result.Features))) - featureItems = append(featureItems, item) + items := make([]styles.DescriptionItem, len(result.Features)) + for i, f := range result.Features { + items[i] = styles.DescriptionItem{Name: f.Name, Description: f.Description} } - fmt.Fprintln(cmd.OutOrStdout(), styles.List(featureItems...)) + fmt.Fprintln(cmd.OutOrStdout(), styles.List(styles.DescriptionList(items)...)) styles.PrintHints(cmd.OutOrStdout(), [][]string{ {"ws-cli feature install ", "Install a feature"}, diff --git a/cmd/feature/store.go b/cmd/feature/store.go new file mode 100644 index 0000000..403f6b2 --- /dev/null +++ b/cmd/feature/store.go @@ -0,0 +1,48 @@ +package feature + +import ( + "fmt" + + "github.com/kloudkit/ws-cli/internals/config" + "github.com/kloudkit/ws-cli/internals/env" + "github.com/kloudkit/ws-cli/internals/features" + "github.com/kloudkit/ws-cli/internals/styles" + "github.com/spf13/cobra" +) + +var storeCmd = &cobra.Command{ + Use: "store", + Short: "List packages available in the feature store", + RunE: func(cmd *cobra.Command, args []string) error { + storeURL := env.String(config.EnvFeaturesStoreURL) + if storeURL == "" { + styles.PrintWarning(cmd.OutOrStdout(), "Feature store not configured (set WS_FEATURES_STORE_URL)") + return nil + } + + manifest, err := features.FetchStoreManifest(storeURL) + if err != nil { + return fmt.Errorf("failed to fetch store manifest: %w", err) + } + + if len(manifest.Artifacts) == 0 { + styles.PrintWarning(cmd.OutOrStdout(), "Feature store is empty") + return nil + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.TitleWithCount("Feature Store", len(manifest.Artifacts))) + + items := make([]styles.DescriptionItem, len(manifest.Artifacts)) + for i, a := range manifest.Artifacts { + items[i] = styles.DescriptionItem{Name: a.Name, Description: a.Version} + } + + fmt.Fprintln(cmd.OutOrStdout(), styles.List(styles.DescriptionList(items)...)) + + if manifest.Built != "" { + fmt.Fprintf(cmd.OutOrStdout(), "\n %s\n", styles.Muted().Render("Built: "+manifest.Built)) + } + + return nil + }, +} diff --git a/cmd/info/metrics.go b/cmd/info/metrics.go index 877fc2d..1d2563f 100644 --- a/cmd/info/metrics.go +++ b/cmd/info/metrics.go @@ -9,81 +9,13 @@ import ( "github.com/kloudkit/ws-cli/internals/styles" ) -type workspaceMetrics struct { - cpuUsage float64 - cpuSeconds float64 - memoryTotal uint64 - memoryUsed uint64 - memoryRSS uint64 - diskTotal uint64 - diskUsed uint64 - fdOpen uint64 - fdLimit uint64 - gpu *metrics.GPUStats -} - -func formatCPUTime(seconds float64) string { - switch { - case seconds < 60: - return fmt.Sprintf("%.1fs", seconds) - case seconds < 3600: - return fmt.Sprintf("%.1fm", seconds/60) - default: - return fmt.Sprintf("%.1fh", seconds/3600) - } -} - -func getMetrics(includeGPU bool) (*workspaceMetrics, error) { - cpuUsage, _ := metrics.GetCPUUsagePercent() - - cpuStats, err := metrics.GetCPUStats() - if err != nil { - return nil, fmt.Errorf("failed to get CPU stats: %w", err) - } - - memStats, err := metrics.GetMemoryStats() - if err != nil { - return nil, fmt.Errorf("failed to get memory stats: %w", err) - } - - diskStats, err := metrics.GetDiskStats() - if err != nil { - return nil, fmt.Errorf("failed to get disk stats: %w", err) - } - - fdStats, err := metrics.GetFileDescriptorStats() - if err != nil { - return nil, fmt.Errorf("failed to get file descriptor stats: %w", err) - } - - m := &workspaceMetrics{ - cpuUsage: cpuUsage, - cpuSeconds: cpuStats.UsageSeconds, - memoryTotal: memStats.LimitBytes, - memoryUsed: memStats.UsageBytes, - memoryRSS: memStats.RSSBytes, - diskTotal: diskStats.LimitBytes, - diskUsed: diskStats.UsageBytes, - fdOpen: fdStats.Open, - fdLimit: fdStats.Limit, - } - - if includeGPU { - if gpuStats, err := metrics.GetGPUStats(); err == nil { - m.gpu = gpuStats - } - } - - return m, nil -} - var metricsCmd = &cobra.Command{ Use: "metrics", Short: "Display workspace metrics", RunE: func(cmd *cobra.Command, args []string) error { includeGPU, _ := cmd.Flags().GetBool("gpu") - m, err := getMetrics(includeGPU) + m, err := metrics.GetWorkspaceSummary(includeGPU) if err != nil { styles.PrintWarning(cmd.OutOrStdout(), "Could not read workspace metrics") return nil @@ -92,27 +24,27 @@ var metricsCmd = &cobra.Command{ fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.Title().Render("Metrics")) rows := [][]string{ - {"CPU", fmt.Sprintf("%.1f%% (%s)", m.cpuUsage, formatCPUTime(m.cpuSeconds))}, + {"CPU", fmt.Sprintf("%.1f%% (%s)", m.CPUUsage, styles.FormatCPUTime(m.CPUSeconds))}, {"Memory", fmt.Sprintf("%s / %s (%s)", - styles.FormatBytes(m.memoryUsed), - styles.FormatBytes(m.memoryTotal), - styles.FormatPercent(m.memoryUsed, m.memoryTotal))}, - {"Memory RSS", styles.FormatBytes(m.memoryRSS)}, + styles.FormatBytes(m.MemoryUsed), + styles.FormatBytes(m.MemoryTotal), + styles.FormatPercent(m.MemoryUsed, m.MemoryTotal))}, + {"Memory RSS", styles.FormatBytes(m.MemoryRSS)}, {"Disk", fmt.Sprintf("%s / %s (%s)", - styles.FormatBytes(m.diskUsed), - styles.FormatBytes(m.diskTotal), - styles.FormatPercent(m.diskUsed, m.diskTotal))}, - {"File Descriptors", fmt.Sprintf("%d / %d", m.fdOpen, m.fdLimit)}, + styles.FormatBytes(m.DiskUsed), + styles.FormatBytes(m.DiskTotal), + styles.FormatPercent(m.DiskUsed, m.DiskTotal))}, + {"File Descriptors", fmt.Sprintf("%d / %d", m.FDOpen, m.FDLimit)}, } - if m.gpu != nil && m.gpu.Available { + if m.GPU != nil && m.GPU.Available { rows = append(rows, - []string{"GPU Utilization", fmt.Sprintf("%.0f%%", m.gpu.UtilizationRatio*100)}, + []string{"GPU Utilization", fmt.Sprintf("%.0f%%", m.GPU.UtilizationRatio*100)}, []string{"GPU Memory", fmt.Sprintf("%s / %s", - styles.FormatBytes(m.gpu.MemoryUsedBytes), - styles.FormatBytes(m.gpu.MemoryTotalBytes))}, - []string{"GPU Temperature", fmt.Sprintf("%.0fC", m.gpu.TemperatureCelsius)}, - []string{"GPU Power", fmt.Sprintf("%.1fW", m.gpu.PowerWatts)}, + styles.FormatBytes(m.GPU.MemoryUsedBytes), + styles.FormatBytes(m.GPU.MemoryTotalBytes))}, + []string{"GPU Temperature", fmt.Sprintf("%.0fC", m.GPU.TemperatureCelsius)}, + []string{"GPU Power", fmt.Sprintf("%.1fW", m.GPU.PowerWatts)}, ) } diff --git a/cmd/info/uptime.go b/cmd/info/uptime.go index d300dd7..a45683f 100644 --- a/cmd/info/uptime.go +++ b/cmd/info/uptime.go @@ -2,8 +2,6 @@ package info import ( "fmt" - "strings" - "time" "github.com/spf13/cobra" @@ -11,32 +9,6 @@ import ( "github.com/kloudkit/ws-cli/internals/styles" ) -func humanizeDuration(duration time.Duration) string { - days := int(duration.Hours() / 24) - hours := int(duration.Hours()) % 24 - minutes := int(duration.Minutes()) % 60 - - var parts []string - - if days > 0 { - parts = append(parts, fmt.Sprintf("%d days", days)) - } - - if hours > 0 { - parts = append(parts, fmt.Sprintf("%d hours", hours)) - } - - if minutes > 0 { - parts = append(parts, fmt.Sprintf("%d minutes", minutes)) - } - - if len(parts) == 0 { - return "just now" - } - - return strings.Join(parts, ", ") + " ago" -} - var uptimeCmd = &cobra.Command{ Use: "uptime", Short: "Display the workspace uptime", @@ -62,7 +34,7 @@ var uptimeCmd = &cobra.Command{ t := styles.Table().Rows( []string{"Started at", styles.Code().Render(started.Format("2006-01-02 15:04:05 MST"))}, - []string{"Running for", humanizeDuration(running)}, + []string{"Running for", styles.FormatDuration(running)}, []string{"Status", statusValue}, ) diff --git a/cmd/info/version.go b/cmd/info/version.go index 972f9e2..97bf617 100644 --- a/cmd/info/version.go +++ b/cmd/info/version.go @@ -1,3 +1,3 @@ package info -var Version = "0.0.49" +var Version = "0.0.50" diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go index c111258..f634f30 100644 --- a/cmd/secrets/secrets.go +++ b/cmd/secrets/secrets.go @@ -1,6 +1,7 @@ package secrets import ( + "github.com/kloudkit/ws-cli/cmd/secrets/vault" "github.com/spf13/cobra" ) @@ -16,5 +17,5 @@ func init() { SecretsCmd.PersistentFlags().Bool("force", false, "Overwrite existing files") SecretsCmd.PersistentFlags().Bool("raw", false, "Output without styling") - SecretsCmd.AddCommand(encryptCmd, decryptCmd, generateCmd, vaultCmd) + SecretsCmd.AddCommand(encryptCmd, decryptCmd, generateCmd, vault.VaultCmd) } diff --git a/cmd/secrets/vault.go b/cmd/secrets/vault/decrypt.go similarity index 87% rename from cmd/secrets/vault.go rename to cmd/secrets/vault/decrypt.go index ed8a476..9d20def 100644 --- a/cmd/secrets/vault.go +++ b/cmd/secrets/vault/decrypt.go @@ -1,4 +1,4 @@ -package secrets +package vault import ( "fmt" @@ -10,9 +10,9 @@ import ( "github.com/spf13/cobra" ) -var vaultCmd = &cobra.Command{ - Use: "vault", - Short: "Decrypt a vault spec with encrypted values", +var decryptCmd = &cobra.Command{ + Use: "decrypt", + Short: "Decrypt vault secrets and write to destinations", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { inputFile, _ := cmd.Flags().GetString("input") @@ -67,9 +67,8 @@ var vaultCmd = &cobra.Command{ } func init() { - vaultCmd.Flags().String("input", "", "Path to vault file") - vaultCmd.Flags().StringArray("key", []string{}, "Decrypt only specified key") - vaultCmd.Flags().Bool("stdout", false, "Output decrypted values to stdout") + decryptCmd.Flags().StringArray("key", []string{}, "Decrypt only specified key") + decryptCmd.Flags().Bool("stdout", false, "Output decrypted values to stdout") } func sortedKeys(m map[string]string) []string { diff --git a/cmd/secrets/vault/ls.go b/cmd/secrets/vault/ls.go new file mode 100644 index 0000000..43cda09 --- /dev/null +++ b/cmd/secrets/vault/ls.go @@ -0,0 +1,50 @@ +package vault + +import ( + "fmt" + + internalSecrets "github.com/kloudkit/ws-cli/internals/secrets" + "github.com/kloudkit/ws-cli/internals/styles" + "github.com/spf13/cobra" +) + +var lsCmd = &cobra.Command{ + Use: "ls", + Short: "List secrets in a vault", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + inputFile, _ := cmd.Flags().GetString("input") + raw, _ := cmd.Flags().GetBool("raw") + + vaultPath, err := internalSecrets.ResolveVaultPath(inputFile) + if err != nil { + return err + } + + vault, err := internalSecrets.LoadRawVault(vaultPath) + if err != nil { + return err + } + + entries := internalSecrets.ListVault(vault) + if len(entries) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), styles.Muted().Render("No secrets in vault")) + return nil + } + + if raw { + for _, e := range entries { + fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", e.Name, e.Type, e.Destination) + } + return nil + } + + t := styles.Table("Name", "Type", "Destination") + for _, e := range entries { + t.Row(e.Name, e.Type, e.Destination) + } + fmt.Fprintln(cmd.OutOrStdout(), t.Render()) + + return nil + }, +} diff --git a/cmd/secrets/vault/rotate.go b/cmd/secrets/vault/rotate.go new file mode 100644 index 0000000..97e269a --- /dev/null +++ b/cmd/secrets/vault/rotate.go @@ -0,0 +1,72 @@ +package vault + +import ( + "fmt" + + internalSecrets "github.com/kloudkit/ws-cli/internals/secrets" + "github.com/kloudkit/ws-cli/internals/styles" + "github.com/spf13/cobra" +) + +var rotateCmd = &cobra.Command{ + Use: "rotate", + Short: "Re-encrypt vault secrets with a new master key", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + inputFile, _ := cmd.Flags().GetString("input") + masterKeyFlag, _ := cmd.Flags().GetString("master") + newMasterFlag, _ := cmd.Flags().GetString("new-master") + raw, _ := cmd.Flags().GetBool("raw") + + vaultPath, err := internalSecrets.ResolveVaultPath(inputFile) + if err != nil { + return err + } + + oldKey, err := internalSecrets.ResolveMasterKey(masterKeyFlag) + if err != nil { + return err + } + + newKey, err := internalSecrets.ResolveMasterKey(newMasterFlag) + if err != nil { + return fmt.Errorf("new master key: %w", err) + } + + vault, err := internalSecrets.LoadRawVault(vaultPath) + if err != nil { + return err + } + + fileRefs, err := internalSecrets.RotateVault(vault, oldKey, newKey) + if err != nil { + return err + } + + if err := internalSecrets.SaveVault(vaultPath, vault); err != nil { + return err + } + + if raw { + fmt.Fprintf(cmd.OutOrStdout(), "%d\n", len(vault.Secrets)) + return nil + } + + if len(fileRefs) > 0 { + fmt.Fprintln(cmd.OutOrStdout(), styles.Warning().Render("⚠ The following secrets had file: references and are now inlined:")) + for _, name := range fileRefs { + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", styles.Code().Render(name)) + } + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s\n", + styles.Success().Render(fmt.Sprintf("βœ“ Rotated %d secret(s)", len(vault.Secrets)))) + + return nil + }, +} + +func init() { + rotateCmd.Flags().String("new-master", "", "New master key or path to key file") + rotateCmd.MarkFlagRequired("new-master") +} diff --git a/cmd/secrets/vault/vault.go b/cmd/secrets/vault/vault.go new file mode 100644 index 0000000..2058abf --- /dev/null +++ b/cmd/secrets/vault/vault.go @@ -0,0 +1,14 @@ +package vault + +import "github.com/spf13/cobra" + +var VaultCmd = &cobra.Command{ + Use: "vault", + Short: "Manage vault secrets", +} + +func init() { + VaultCmd.PersistentFlags().String("input", "", "Path to vault file") + + VaultCmd.AddCommand(lsCmd, decryptCmd, rotateCmd) +} diff --git a/cmd/serve/metrics.go b/cmd/serve/metrics.go index c821d06..f8c251d 100644 --- a/cmd/serve/metrics.go +++ b/cmd/serve/metrics.go @@ -1,589 +1,15 @@ package serve import ( - "errors" "fmt" "net/http" - "slices" - "strconv" - "strings" - "github.com/kloudkit/ws-cli/internals/config" - "github.com/kloudkit/ws-cli/internals/env" "github.com/kloudkit/ws-cli/internals/metrics" "github.com/kloudkit/ws-cli/internals/styles" - "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/spf13/cobra" ) -const metricsNamespace = "workspace" - -var validCollectors = map[string][]string{ - "workspace": {"workspace.info", "workspace.session", "workspace.extensions"}, - "workspace.info": {}, - "workspace.session": {}, - "workspace.extensions": {}, - "container": {"container.cpu", "container.memory", "container.fs", "container.fd", "container.pids"}, - "container.cpu": {}, - "container.memory": {}, - "container.fs": {}, - "container.fd": {}, - "container.pids": {}, - "pressure": {"pressure.cpu", "pressure.memory", "pressure.io"}, - "pressure.cpu": {}, - "pressure.memory": {}, - "pressure.io": {}, - "network": {}, - "io": {}, - "sockets": {}, - "gpu": {}, -} - -var allLeafCollectors = []string{ - "container.cpu", - "container.fd", - "container.fs", - "container.memory", - "container.pids", - "io", - "network", - "pressure.cpu", - "pressure.io", - "pressure.memory", - "sockets", - "workspace.extensions", - "workspace.info", - "workspace.session", -} - -func isCollectorEnabled(name string, collectors []string) bool { - if len(collectors) == 0 || slices.Contains(collectors, "*") { - return true - } - - for _, c := range collectors { - if c == name || strings.HasPrefix(name, c+".") || strings.HasPrefix(c, name+".") { - return true - } - } - - return false -} - -func expandCollectors(collectors []string) []string { - if len(collectors) == 0 || slices.Contains(collectors, "*") { - result := make([]string, 0, len(allLeafCollectors)+2) - for _, c := range allLeafCollectors { - // Skip pressure collectors if not available - if strings.HasPrefix(c, "pressure.") && !metrics.IsPressureAvailable() { - continue - } - result = append(result, c) - } - if metrics.IsGPUAvailable() { - result = append(result, "gpu") - } - return result - } - - expanded := make(map[string]bool) - for _, c := range collectors { - subs := validCollectors[c] - if len(subs) == 0 { - expanded[c] = true - continue - } - for _, sub := range subs { - expanded[sub] = true - } - } - - result := make([]string, 0, len(expanded)) - for c := range expanded { - result = append(result, c) - } - slices.Sort(result) - return result -} - -func newDesc(subsystem, name, description string) *prometheus.Desc { - return prometheus.NewDesc( - prometheus.BuildFQName(metricsNamespace, subsystem, name), - description, - nil, nil, - ) -} - -type workspaceCollector struct { - info *prometheus.Desc - initializedTimestamp *prometheus.Desc - uptimeSeconds *prometheus.Desc - extensionsInstalledTotal *prometheus.Desc - infoLabels prometheus.Labels - initializedUnix float64 - enabled []string -} - -func newWorkspaceCollector(enabled []string) *workspaceCollector { - c := &workspaceCollector{ - info: prometheus.NewDesc( - prometheus.BuildFQName(metricsNamespace, "", "info"), - "Workspace build information", - []string{"version", "vscode_version"}, - nil, - ), - initializedTimestamp: newDesc("session", "initialized_timestamp_seconds", "Unix timestamp when workspace was initialized"), - uptimeSeconds: newDesc("session", "uptime_seconds", "Seconds since workspace was initialized"), - extensionsInstalledTotal: newDesc("", "extensions_installed_total", "Number of VS Code extensions installed"), - infoLabels: prometheus.Labels{"version": "", "vscode_version": ""}, - enabled: enabled, - } - - if manifest, err := config.ReadManifest(); err == nil { - c.infoLabels = prometheus.Labels{ - "version": manifest.Version, - "vscode_version": manifest.VSCode.Version, - } - } - - if initialized, err := config.GetInitializedTime(); err == nil { - c.initializedUnix = float64(initialized.Unix()) - } - - return c -} - -func (c *workspaceCollector) has(name string) bool { - return isCollectorEnabled(name, c.enabled) -} - -func (c *workspaceCollector) Describe(ch chan<- *prometheus.Desc) { - if c.has("workspace.info") { - ch <- c.info - } - if c.has("workspace.session") { - ch <- c.initializedTimestamp - ch <- c.uptimeSeconds - } - if c.has("workspace.extensions") { - ch <- c.extensionsInstalledTotal - } -} - -func (c *workspaceCollector) Collect(ch chan<- prometheus.Metric) { - if c.has("workspace.info") { - ch <- prometheus.MustNewConstMetric( - c.info, prometheus.GaugeValue, 1, - c.infoLabels["version"], c.infoLabels["vscode_version"], - ) - } - - if c.has("workspace.session") { - ch <- prometheus.MustNewConstMetric(c.initializedTimestamp, prometheus.GaugeValue, c.initializedUnix) - if uptime, err := config.GetUptime(); err == nil { - ch <- prometheus.MustNewConstMetric(c.uptimeSeconds, prometheus.GaugeValue, uptime.Seconds()) - } - } - - if c.has("workspace.extensions") { - if count, err := config.GetExtensionCount(); err == nil { - ch <- prometheus.MustNewConstMetric(c.extensionsInstalledTotal, prometheus.GaugeValue, float64(count)) - } - } -} - -type containerCollector struct { - // CPU metrics - cpuUsageSeconds *prometheus.Desc - cpuUserSeconds *prometheus.Desc - cpuSystemSeconds *prometheus.Desc - cpuThrottledPeriods *prometheus.Desc - cpuThrottledSeconds *prometheus.Desc - cpuPeriodsTotal *prometheus.Desc - // Memory metrics - memUsageBytes *prometheus.Desc - memLimitBytes *prometheus.Desc - memRSSBytes *prometheus.Desc - memCacheBytes *prometheus.Desc - memSwapBytes *prometheus.Desc - memSwapLimitBytes *prometheus.Desc - memAnonBytes *prometheus.Desc - memKernelBytes *prometheus.Desc - memSlabBytes *prometheus.Desc - memOOMTotal *prometheus.Desc - memOOMKillTotal *prometheus.Desc - memMaxTotal *prometheus.Desc - // Filesystem metrics - fsUsageBytes *prometheus.Desc - fsLimitBytes *prometheus.Desc - // File descriptor metrics - fdOpen *prometheus.Desc - fdLimit *prometheus.Desc - // PID metrics - pidsCurrent *prometheus.Desc - pidsLimit *prometheus.Desc - enabled []string -} - -func newContainerCollector(enabled []string) *containerCollector { - desc := func(name, description string) *prometheus.Desc { - return newDesc("container", name, description) - } - return &containerCollector{ - // CPU metrics - cpuUsageSeconds: desc("cpu_usage_seconds_total", "Total CPU time consumed by the container"), - cpuUserSeconds: desc("cpu_user_seconds_total", "CPU time consumed in user mode"), - cpuSystemSeconds: desc("cpu_system_seconds_total", "CPU time consumed in system mode"), - cpuThrottledPeriods: desc("cpu_throttled_periods_total", "Number of throttled CPU periods"), - cpuThrottledSeconds: desc("cpu_throttled_seconds_total", "Total time throttled in seconds"), - cpuPeriodsTotal: desc("cpu_periods_total", "Total number of CPU scheduling periods"), - // Memory metrics - memUsageBytes: desc("memory_usage_bytes", "Current memory usage in bytes"), - memLimitBytes: desc("memory_limit_bytes", "Memory limit in bytes"), - memRSSBytes: desc("memory_rss_bytes", "Resident set size in bytes"), - memCacheBytes: desc("memory_cache_bytes", "Page cache memory in bytes"), - memSwapBytes: desc("memory_swap_bytes", "Swap usage in bytes"), - memSwapLimitBytes: desc("memory_swap_limit_bytes", "Swap limit in bytes"), - memAnonBytes: desc("memory_anon_bytes", "Anonymous memory in bytes"), - memKernelBytes: desc("memory_kernel_bytes", "Kernel memory in bytes"), - memSlabBytes: desc("memory_slab_bytes", "Slab allocator memory in bytes"), - memOOMTotal: desc("memory_oom_total", "Number of OOM events"), - memOOMKillTotal: desc("memory_oom_kill_total", "Number of OOM kill events"), - memMaxTotal: desc("memory_max_total", "Number of times memory limit was hit"), - // Filesystem metrics - fsUsageBytes: desc("fs_usage_bytes", "Filesystem usage in bytes on /workspace"), - fsLimitBytes: desc("fs_limit_bytes", "Filesystem capacity in bytes on /workspace"), - // File descriptor metrics - fdOpen: desc("file_descriptors_open", "Number of open file descriptors"), - fdLimit: desc("file_descriptors_limit", "File descriptor limit"), - // PID metrics - pidsCurrent: desc("pids_current", "Current number of processes"), - pidsLimit: desc("pids_limit", "Process limit"), - enabled: enabled, - } -} - -func (c *containerCollector) has(name string) bool { - return isCollectorEnabled(name, c.enabled) -} - -func (c *containerCollector) Describe(ch chan<- *prometheus.Desc) { - if c.has("container.cpu") { - ch <- c.cpuUsageSeconds - ch <- c.cpuUserSeconds - ch <- c.cpuSystemSeconds - ch <- c.cpuThrottledPeriods - ch <- c.cpuThrottledSeconds - ch <- c.cpuPeriodsTotal - } - if c.has("container.memory") { - ch <- c.memUsageBytes - ch <- c.memLimitBytes - ch <- c.memRSSBytes - ch <- c.memCacheBytes - ch <- c.memSwapBytes - ch <- c.memSwapLimitBytes - ch <- c.memAnonBytes - ch <- c.memKernelBytes - ch <- c.memSlabBytes - ch <- c.memOOMTotal - ch <- c.memOOMKillTotal - ch <- c.memMaxTotal - } - if c.has("container.fs") { - ch <- c.fsUsageBytes - ch <- c.fsLimitBytes - } - if c.has("container.fd") { - ch <- c.fdOpen - ch <- c.fdLimit - } - if c.has("container.pids") { - ch <- c.pidsCurrent - ch <- c.pidsLimit - } -} - -func (c *containerCollector) Collect(ch chan<- prometheus.Metric) { - if c.has("container.cpu") { - if cpu, err := metrics.GetCPUStats(); err == nil { - ch <- prometheus.MustNewConstMetric(c.cpuUsageSeconds, prometheus.CounterValue, cpu.UsageSeconds) - ch <- prometheus.MustNewConstMetric(c.cpuUserSeconds, prometheus.CounterValue, cpu.UserSeconds) - ch <- prometheus.MustNewConstMetric(c.cpuSystemSeconds, prometheus.CounterValue, cpu.SystemSeconds) - ch <- prometheus.MustNewConstMetric(c.cpuThrottledPeriods, prometheus.CounterValue, float64(cpu.ThrottledPeriods)) - ch <- prometheus.MustNewConstMetric(c.cpuThrottledSeconds, prometheus.CounterValue, cpu.ThrottledSeconds) - ch <- prometheus.MustNewConstMetric(c.cpuPeriodsTotal, prometheus.CounterValue, float64(cpu.TotalPeriods)) - } - } - - if c.has("container.memory") { - if mem, err := metrics.GetMemoryStats(); err == nil { - ch <- prometheus.MustNewConstMetric(c.memUsageBytes, prometheus.GaugeValue, float64(mem.UsageBytes)) - ch <- prometheus.MustNewConstMetric(c.memLimitBytes, prometheus.GaugeValue, float64(mem.LimitBytes)) - ch <- prometheus.MustNewConstMetric(c.memRSSBytes, prometheus.GaugeValue, float64(mem.RSSBytes)) - ch <- prometheus.MustNewConstMetric(c.memCacheBytes, prometheus.GaugeValue, float64(mem.CacheBytes)) - ch <- prometheus.MustNewConstMetric(c.memSwapBytes, prometheus.GaugeValue, float64(mem.SwapBytes)) - ch <- prometheus.MustNewConstMetric(c.memSwapLimitBytes, prometheus.GaugeValue, float64(mem.SwapLimitBytes)) - ch <- prometheus.MustNewConstMetric(c.memAnonBytes, prometheus.GaugeValue, float64(mem.AnonBytes)) - ch <- prometheus.MustNewConstMetric(c.memKernelBytes, prometheus.GaugeValue, float64(mem.KernelBytes)) - ch <- prometheus.MustNewConstMetric(c.memSlabBytes, prometheus.GaugeValue, float64(mem.SlabBytes)) - ch <- prometheus.MustNewConstMetric(c.memOOMTotal, prometheus.CounterValue, float64(mem.OOMEvents)) - ch <- prometheus.MustNewConstMetric(c.memOOMKillTotal, prometheus.CounterValue, float64(mem.OOMKillEvents)) - ch <- prometheus.MustNewConstMetric(c.memMaxTotal, prometheus.CounterValue, float64(mem.MaxEvents)) - } - } - - if c.has("container.fs") { - if disk, err := metrics.GetDiskStats(); err == nil { - ch <- prometheus.MustNewConstMetric(c.fsUsageBytes, prometheus.GaugeValue, float64(disk.UsageBytes)) - ch <- prometheus.MustNewConstMetric(c.fsLimitBytes, prometheus.GaugeValue, float64(disk.LimitBytes)) - } - } - - if c.has("container.fd") { - if fd, err := metrics.GetFileDescriptorStats(); err == nil { - ch <- prometheus.MustNewConstMetric(c.fdOpen, prometheus.GaugeValue, float64(fd.Open)) - ch <- prometheus.MustNewConstMetric(c.fdLimit, prometheus.GaugeValue, float64(fd.Limit)) - } - } - - if c.has("container.pids") { - if pids, err := metrics.GetPIDStats(); err == nil { - ch <- prometheus.MustNewConstMetric(c.pidsCurrent, prometheus.GaugeValue, float64(pids.Current)) - ch <- prometheus.MustNewConstMetric(c.pidsLimit, prometheus.GaugeValue, float64(pids.Limit)) - } - } -} - -type gpuCollector struct { - utilizationRatio *prometheus.Desc - memoryUsedBytes *prometheus.Desc - memoryTotalBytes *prometheus.Desc - temperatureCelsius *prometheus.Desc - powerWatts *prometheus.Desc -} - -func newGPUCollector() *gpuCollector { - desc := func(name, description string) *prometheus.Desc { - return newDesc("gpu", name, description) - } - return &gpuCollector{ - utilizationRatio: desc("utilization_ratio", "GPU utilization ratio (0-1)"), - memoryUsedBytes: desc("memory_used_bytes", "GPU memory used in bytes"), - memoryTotalBytes: desc("memory_total_bytes", "GPU memory total in bytes"), - temperatureCelsius: desc("temperature_celsius", "GPU temperature in Celsius"), - powerWatts: desc("power_watts", "GPU power consumption in watts"), - } -} - -func (c *gpuCollector) Describe(ch chan<- *prometheus.Desc) { - ch <- c.utilizationRatio - ch <- c.memoryUsedBytes - ch <- c.memoryTotalBytes - ch <- c.temperatureCelsius - ch <- c.powerWatts -} - -func (c *gpuCollector) Collect(ch chan<- prometheus.Metric) { - stats, err := metrics.GetGPUStats() - if err != nil || !stats.Available { - return - } - - ch <- prometheus.MustNewConstMetric(c.utilizationRatio, prometheus.GaugeValue, stats.UtilizationRatio) - ch <- prometheus.MustNewConstMetric(c.memoryUsedBytes, prometheus.GaugeValue, float64(stats.MemoryUsedBytes)) - ch <- prometheus.MustNewConstMetric(c.memoryTotalBytes, prometheus.GaugeValue, float64(stats.MemoryTotalBytes)) - ch <- prometheus.MustNewConstMetric(c.temperatureCelsius, prometheus.GaugeValue, stats.TemperatureCelsius) - ch <- prometheus.MustNewConstMetric(c.powerWatts, prometheus.GaugeValue, stats.PowerWatts) -} - -// Pressure collector for PSI metrics -type pressureCollector struct { - cpuWaitingSeconds *prometheus.Desc - cpuStalledSeconds *prometheus.Desc - memoryWaitingSeconds *prometheus.Desc - memoryStalledSeconds *prometheus.Desc - ioWaitingSeconds *prometheus.Desc - ioStalledSeconds *prometheus.Desc - enabled []string -} - -func newPressureCollector(enabled []string) *pressureCollector { - desc := func(name, description string) *prometheus.Desc { - return newDesc("pressure", name, description) - } - return &pressureCollector{ - cpuWaitingSeconds: desc("cpu_waiting_seconds_total", "Total time tasks waited for CPU"), - cpuStalledSeconds: desc("cpu_stalled_seconds_total", "Total time all tasks were stalled on CPU"), - memoryWaitingSeconds: desc("memory_waiting_seconds_total", "Total time tasks waited for memory"), - memoryStalledSeconds: desc("memory_stalled_seconds_total", "Total time all tasks were stalled on memory"), - ioWaitingSeconds: desc("io_waiting_seconds_total", "Total time tasks waited for I/O"), - ioStalledSeconds: desc("io_stalled_seconds_total", "Total time all tasks were stalled on I/O"), - enabled: enabled, - } -} - -func (c *pressureCollector) has(name string) bool { - return isCollectorEnabled(name, c.enabled) -} - -func (c *pressureCollector) Describe(ch chan<- *prometheus.Desc) { - if c.has("pressure.cpu") { - ch <- c.cpuWaitingSeconds - ch <- c.cpuStalledSeconds - } - if c.has("pressure.memory") { - ch <- c.memoryWaitingSeconds - ch <- c.memoryStalledSeconds - } - if c.has("pressure.io") { - ch <- c.ioWaitingSeconds - ch <- c.ioStalledSeconds - } -} - -func (c *pressureCollector) Collect(ch chan<- prometheus.Metric) { - stats, err := metrics.GetPressureStats() - if err != nil { - return - } - - if c.has("pressure.cpu") { - ch <- prometheus.MustNewConstMetric(c.cpuWaitingSeconds, prometheus.CounterValue, stats.CPUWaitingSeconds) - ch <- prometheus.MustNewConstMetric(c.cpuStalledSeconds, prometheus.CounterValue, stats.CPUStalledSeconds) - } - if c.has("pressure.memory") { - ch <- prometheus.MustNewConstMetric(c.memoryWaitingSeconds, prometheus.CounterValue, stats.MemoryWaitingSeconds) - ch <- prometheus.MustNewConstMetric(c.memoryStalledSeconds, prometheus.CounterValue, stats.MemoryStalledSeconds) - } - if c.has("pressure.io") { - ch <- prometheus.MustNewConstMetric(c.ioWaitingSeconds, prometheus.CounterValue, stats.IOWaitingSeconds) - ch <- prometheus.MustNewConstMetric(c.ioStalledSeconds, prometheus.CounterValue, stats.IOStalledSeconds) - } -} - -// Network collector for network I/O metrics -type networkCollector struct { - receiveBytesTotal *prometheus.Desc - transmitBytesTotal *prometheus.Desc - receivePacketsTotal *prometheus.Desc - transmitPacketsTotal *prometheus.Desc - receiveErrorsTotal *prometheus.Desc - transmitErrorsTotal *prometheus.Desc -} - -func newNetworkCollector() *networkCollector { - desc := func(name, description string) *prometheus.Desc { - return newDesc("network", name, description) - } - return &networkCollector{ - receiveBytesTotal: desc("receive_bytes_total", "Total bytes received"), - transmitBytesTotal: desc("transmit_bytes_total", "Total bytes transmitted"), - receivePacketsTotal: desc("receive_packets_total", "Total packets received"), - transmitPacketsTotal: desc("transmit_packets_total", "Total packets transmitted"), - receiveErrorsTotal: desc("receive_errors_total", "Total receive errors"), - transmitErrorsTotal: desc("transmit_errors_total", "Total transmit errors"), - } -} - -func (c *networkCollector) Describe(ch chan<- *prometheus.Desc) { - ch <- c.receiveBytesTotal - ch <- c.transmitBytesTotal - ch <- c.receivePacketsTotal - ch <- c.transmitPacketsTotal - ch <- c.receiveErrorsTotal - ch <- c.transmitErrorsTotal -} - -func (c *networkCollector) Collect(ch chan<- prometheus.Metric) { - stats, err := metrics.GetNetworkStats() - if err != nil { - return - } - - ch <- prometheus.MustNewConstMetric(c.receiveBytesTotal, prometheus.CounterValue, float64(stats.ReceiveBytesTotal)) - ch <- prometheus.MustNewConstMetric(c.transmitBytesTotal, prometheus.CounterValue, float64(stats.TransmitBytesTotal)) - ch <- prometheus.MustNewConstMetric(c.receivePacketsTotal, prometheus.CounterValue, float64(stats.ReceivePacketsTotal)) - ch <- prometheus.MustNewConstMetric(c.transmitPacketsTotal, prometheus.CounterValue, float64(stats.TransmitPacketsTotal)) - ch <- prometheus.MustNewConstMetric(c.receiveErrorsTotal, prometheus.CounterValue, float64(stats.ReceiveErrorsTotal)) - ch <- prometheus.MustNewConstMetric(c.transmitErrorsTotal, prometheus.CounterValue, float64(stats.TransmitErrorsTotal)) -} - -// IO collector for disk I/O throughput metrics -type ioCollector struct { - readBytesTotal *prometheus.Desc - writeBytesTotal *prometheus.Desc - readOpsTotal *prometheus.Desc - writeOpsTotal *prometheus.Desc -} - -func newIOCollector() *ioCollector { - desc := func(name, description string) *prometheus.Desc { - return newDesc("io", name, description) - } - return &ioCollector{ - readBytesTotal: desc("read_bytes_total", "Total bytes read from disk"), - writeBytesTotal: desc("write_bytes_total", "Total bytes written to disk"), - readOpsTotal: desc("read_ops_total", "Total disk read operations"), - writeOpsTotal: desc("write_ops_total", "Total disk write operations"), - } -} - -func (c *ioCollector) Describe(ch chan<- *prometheus.Desc) { - ch <- c.readBytesTotal - ch <- c.writeBytesTotal - ch <- c.readOpsTotal - ch <- c.writeOpsTotal -} - -func (c *ioCollector) Collect(ch chan<- prometheus.Metric) { - stats, err := metrics.GetIOStats() - if err != nil { - return - } - - ch <- prometheus.MustNewConstMetric(c.readBytesTotal, prometheus.CounterValue, float64(stats.ReadBytesTotal)) - ch <- prometheus.MustNewConstMetric(c.writeBytesTotal, prometheus.CounterValue, float64(stats.WriteBytesTotal)) - ch <- prometheus.MustNewConstMetric(c.readOpsTotal, prometheus.CounterValue, float64(stats.ReadOpsTotal)) - ch <- prometheus.MustNewConstMetric(c.writeOpsTotal, prometheus.CounterValue, float64(stats.WriteOpsTotal)) -} - -// Sockets collector for socket statistics -type socketsCollector struct { - tcpEstablished *prometheus.Desc - tcpListen *prometheus.Desc - udp *prometheus.Desc -} - -func newSocketsCollector() *socketsCollector { - desc := func(name, description string) *prometheus.Desc { - return newDesc("sockets", name, description) - } - return &socketsCollector{ - tcpEstablished: desc("tcp_established", "Number of established TCP connections"), - tcpListen: desc("tcp_listen", "Number of listening TCP sockets"), - udp: desc("udp", "Number of UDP sockets"), - } -} - -func (c *socketsCollector) Describe(ch chan<- *prometheus.Desc) { - ch <- c.tcpEstablished - ch <- c.tcpListen - ch <- c.udp -} - -func (c *socketsCollector) Collect(ch chan<- prometheus.Metric) { - stats, err := metrics.GetSocketStats() - if err != nil { - return - } - - ch <- prometheus.MustNewConstMetric(c.tcpEstablished, prometheus.GaugeValue, float64(stats.TCPEstablished)) - ch <- prometheus.MustNewConstMetric(c.tcpListen, prometheus.GaugeValue, float64(stats.TCPListen)) - ch <- prometheus.MustNewConstMetric(c.udp, prometheus.GaugeValue, float64(stats.UDP)) -} - var metricsCmd = &cobra.Command{ Use: "metrics", Short: "Start the Prometheus metrics server", @@ -592,88 +18,28 @@ var metricsCmd = &cobra.Command{ collectors, _ := cmd.Flags().GetStringSlice("collectors") out := cmd.OutOrStdout() - var validated, invalid []string - hasExplicit := len(collectors) > 0 - - for _, c := range collectors { - if c = strings.TrimSpace(c); c == "" { - continue - } - if c == "*" { - validated = []string{"*"} - break - } - if _, ok := validCollectors[c]; ok { - validated = append(validated, c) - } else { - invalid = append(invalid, c) - } - } - styles.PrintTitle(out, "Metrics Server") - for _, c := range invalid { - styles.PrintWarning(out, fmt.Sprintf("Unknown collector '%s', skipping", c)) - } - - hasWorkspace := isCollectorEnabled("workspace", validated) - hasContainer := isCollectorEnabled("container", validated) - hasPressure := isCollectorEnabled("pressure", validated) && metrics.IsPressureAvailable() - hasNetwork := isCollectorEnabled("network", validated) - hasIO := isCollectorEnabled("io", validated) - hasSockets := isCollectorEnabled("sockets", validated) - gpuRequested := slices.Contains(validated, "gpu") - hasGPU := isCollectorEnabled("gpu", validated) && metrics.IsGPUAvailable() - - pressureRequested := slices.Contains(validated, "pressure") || slices.Contains(validated, "pressure.cpu") || slices.Contains(validated, "pressure.memory") || slices.Contains(validated, "pressure.io") - if pressureRequested && !metrics.IsPressureAvailable() { - styles.PrintWarning(out, "PSI pressure metrics not available (cgroup v2 only), skipping pressure collector") - validated = slices.DeleteFunc(validated, func(c string) bool { - return c == "pressure" || strings.HasPrefix(c, "pressure.") - }) - hasPressure = false - } - - if gpuRequested && !hasGPU { - styles.PrintWarning(out, "GPU not available, skipping gpu collector") - validated = slices.DeleteFunc(validated, func(c string) bool { return c == "gpu" }) - } - - if hasExplicit && !hasWorkspace && !hasContainer && !hasPressure && !hasNetwork && !hasIO && !hasSockets && !hasGPU { - return errors.New("no collectors enabled") + result, err := metrics.BuildRegistry(collectors) + if err != nil { + return err } - registry := prometheus.NewRegistry() - if hasWorkspace { - registry.MustRegister(newWorkspaceCollector(validated)) - } - if hasContainer { - registry.MustRegister(newContainerCollector(validated)) - } - if hasPressure { - registry.MustRegister(newPressureCollector(validated)) - } - if hasNetwork { - registry.MustRegister(newNetworkCollector()) - } - if hasIO { - registry.MustRegister(newIOCollector()) - } - if hasSockets { - registry.MustRegister(newSocketsCollector()) + for _, c := range result.Invalid { + styles.PrintWarning(out, fmt.Sprintf("Unknown collector '%s', skipping", c)) } - if hasGPU { - registry.MustRegister(newGPUCollector()) + for _, w := range result.Warnings { + styles.PrintWarning(out, w) } fmt.Fprintln(out, styles.Info().Render(" Collectors:")) - for _, c := range expandCollectors(validated) { + for _, c := range result.Expanded { fmt.Fprintln(out, styles.Muted().Render("\t"+c)) } fmt.Fprintln(out) addr := fmt.Sprintf(":%d", port) - http.Handle("/", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) + http.Handle("/", promhttp.HandlerFor(result.Registry, promhttp.HandlerOpts{})) styles.PrintSuccess(out, fmt.Sprintf("Serving metrics at http://0.0.0.0%s", addr)) fmt.Fprintln(out, styles.Info().Render("Press Ctrl+C to stop")) @@ -682,33 +48,9 @@ var metricsCmd = &cobra.Command{ }, } -func getMetricsDefaultPort() int { - if portStr := env.String(config.EnvMetricsPort); portStr != "" { - if port, err := strconv.Atoi(portStr); err == nil { - return port - } - } - return config.DefaultMetricsPort -} - -func getMetricsDefaultCollectors() []string { - envCollectors := env.String(config.EnvMetricsCollectors) - if envCollectors == "" { - return nil - } - - var collectors []string - for _, c := range strings.Split(envCollectors, ",") { - if c = strings.TrimSpace(c); c != "" { - collectors = append(collectors, c) - } - } - return collectors -} - func init() { - metricsCmd.Flags().IntP("port", "p", getMetricsDefaultPort(), "Port to serve metrics on") - metricsCmd.Flags().StringSlice("collectors", getMetricsDefaultCollectors(), "Comma-separated list of collectors to enable (e.g., workspace,container.cpu,gpu)") + metricsCmd.Flags().IntP("port", "p", metrics.DefaultPort(), "Port to serve metrics on") + metricsCmd.Flags().StringSlice("collectors", metrics.DefaultCollectors(), "Comma-separated list of collectors to enable (e.g., workspace,container.cpu,gpu)") ServeCmd.AddCommand(metricsCmd) } diff --git a/cmd/show/ip.go b/cmd/show/ip.go index 3751be4..9de9d7a 100644 --- a/cmd/show/ip.go +++ b/cmd/show/ip.go @@ -20,7 +20,8 @@ var ipInternalCmd = &cobra.Command{ return err } - if styles.OutputRaw(cmd, ip) { + raw, _ := cmd.Flags().GetBool("raw") + if styles.OutputRaw(cmd.OutOrStdout(), raw, ip) { return nil } @@ -40,7 +41,8 @@ var ipNodeCmd = &cobra.Command{ return err } - if styles.OutputRaw(cmd, ip) { + raw, _ := cmd.Flags().GetBool("raw") + if styles.OutputRaw(cmd.OutOrStdout(), raw, ip) { return nil } diff --git a/cmd/show/path.go b/cmd/show/path.go index 5b31d27..7cabdf8 100644 --- a/cmd/show/path.go +++ b/cmd/show/path.go @@ -19,7 +19,8 @@ var pathHomeCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { homePath := env.String(config.EnvServerRoot, config.DefaultServerRoot) - if styles.OutputRaw(cmd, homePath) { + raw, _ := cmd.Flags().GetBool("raw") + if styles.OutputRaw(cmd.OutOrStdout(), raw, homePath) { return nil } @@ -42,7 +43,8 @@ var pathVscodeCmd = &cobra.Command{ settingsPath = path.GetHomeDirectory("/.local/share/workspace/User/settings.json") } - if styles.OutputRaw(cmd, settingsPath) { + raw, _ := cmd.Flags().GetBool("raw") + if styles.OutputRaw(cmd.OutOrStdout(), raw, settingsPath) { return nil } diff --git a/cmd/template/list.go b/cmd/template/list.go index b67685d..99bcdf1 100644 --- a/cmd/template/list.go +++ b/cmd/template/list.go @@ -41,10 +41,11 @@ func runList(cmd *cobra.Command, args []string) error { fmt.Fprintf(cmd.OutOrStdout(), "%s\n", l.String()) - fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", styles.Muted().Render("Quick actions:")) - fmt.Fprintf(cmd.OutOrStdout(), " %s %s\n", styles.Code().Render("ws-cli template apply "), styles.Muted().Render("Apply a template")) - fmt.Fprintf(cmd.OutOrStdout(), " %s %s\n", styles.Code().Render("ws-cli template show "), styles.Muted().Render("View template contents")) - fmt.Fprintf(cmd.OutOrStdout(), " %s %s\n", styles.Code().Render("ws-cli template show --local "), styles.Muted().Render("View applied template")) + styles.PrintHints(cmd.OutOrStdout(), [][]string{ + {"ws-cli template apply ", "Apply a template"}, + {"ws-cli template show ", "View template contents"}, + {"ws-cli template show --local ", "View applied template"}, + }) return nil } diff --git a/go.mod b/go.mod index 7dcfd61..be4fe6a 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,11 @@ toolchain go1.25.5 require ( github.com/charmbracelet/fang v0.4.1 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 + github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 golang.org/x/crypto v0.46.0 + golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.2 ) @@ -34,7 +36,6 @@ require ( github.com/muesli/mango-pflag v0.2.0 // indirect github.com/muesli/roff v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect @@ -43,7 +44,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/internals/config/defaults.go b/internals/config/defaults.go index 6b3af75..392a56e 100644 --- a/internals/config/defaults.go +++ b/internals/config/defaults.go @@ -11,16 +11,17 @@ const ( EnvIPCSocket = "WS__INTERNAL_IPC_SOCKET" EnvMetricsPort = "WS_METRICS_PORT" EnvMetricsCollectors = "WS_METRICS_COLLECTORS" + EnvFeaturesStoreURL = "WS_FEATURES_STORE_URL" DefaultSecretsKeyPath = "/etc/workspace/master.key" DefaultSecretsVaultPath = "~/.ws/vault/secrets.yaml" - DefaultEnvFilePath = "~/.zshenv" - DefaultLoggingDir = "/var/log/workspace" - DefaultLoggingFile = "workspace.log" - DefaultServerRoot = "/workspace" - DefaultFeaturesDir = "/features" - DefaultIPCSocket = "/var/workspace/ipc.socket" - DefaultManifestPath = "/var/lib/workspace/manifest.json" - DefaultStatePath = "/var/lib/workspace/state" - DefaultMetricsPort = 9100 + DefaultEnvFilePath = "~/.zshenv" + DefaultLoggingDir = "/var/log/workspace" + DefaultLoggingFile = "workspace.log" + DefaultServerRoot = "/workspace" + DefaultFeaturesDir = "/features" + DefaultIPCSocket = "/var/workspace/ipc.socket" + DefaultManifestPath = "/var/lib/workspace/manifest.json" + DefaultStatePath = "/var/lib/workspace/state" + DefaultMetricsPort = 9100 ) diff --git a/internals/features/ansible.go b/internals/features/ansible.go new file mode 100644 index 0000000..0e9afe6 --- /dev/null +++ b/internals/features/ansible.go @@ -0,0 +1,40 @@ +package features + +import ( + "fmt" + "os" + "os/exec" + "slices" + "strings" +) + +func RunPlaybook(featurePath string, vars map[string]any) error { + args := []string{featurePath} + + if len(vars) > 0 { + keys := make([]string, 0, len(vars)) + for key := range vars { + keys = append(keys, key) + } + slices.Sort(keys) + + extraVars := make([]string, 0, len(keys)) + for _, key := range keys { + extraVars = append(extraVars, fmt.Sprintf("%s=%v", key, vars[key])) + } + args = append(args, "--extra-vars", strings.Join(extraVars, " ")) + } + + cmd := exec.Command("ansible-playbook", args...) + cmd.Env = append(os.Environ(), + "ANSIBLE_DISPLAY_OK_HOSTS=0", + "ANSIBLE_DISPLAY_FAILED_STDERR=0", + "ANSIBLE_DISPLAY_SKIPPED_HOSTS=0", + "ANSIBLE_SHOW_CUSTOM_STATS=0", + "ANSIBLE_STDOUT_CALLBACK=community.general.unixy", + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} diff --git a/internals/features/parser.go b/internals/features/parser.go index 4c5a638..d6992e2 100644 --- a/internals/features/parser.go +++ b/internals/features/parser.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "gopkg.in/yaml.v3" @@ -31,6 +32,10 @@ func ParseFeatureFile(filePath string) (*Feature, error) { return nil, fmt.Errorf("failed to parse YAML from %s: %w", filePath, err) } + if len(tasks) == 0 { + return nil, fmt.Errorf("no playbook tasks found in %s", filePath) + } + baseName := filepath.Base(filePath) var vars []string @@ -39,6 +44,7 @@ func ParseFeatureFile(filePath string) (*Feature, error) { vars = append(vars, key) } } + slices.Sort(vars) return &Feature{ Name: strings.TrimSuffix(baseName, ".yaml"), @@ -57,13 +63,18 @@ func InfoFeature(featuresDir, name string) (*Feature, error) { return ParseFeatureFile(featurePath) } -func ListFeatures(featuresDir string) ([]*Feature, error) { +type ListResult struct { + Features []*Feature + Warnings []string +} + +func ListFeatures(featuresDir string) (*ListResult, error) { entries, err := os.ReadDir(featuresDir) if err != nil { return nil, fmt.Errorf("failed to read features directory %s: %w", featuresDir, err) } - var features []*Feature + result := &ListResult{} for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { continue @@ -71,11 +82,12 @@ func ListFeatures(featuresDir string) ([]*Feature, error) { feature, err := ParseFeatureFile(filepath.Join(featuresDir, entry.Name())) if err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("skipped %s: %v", entry.Name(), err)) continue } - features = append(features, feature) + result.Features = append(result.Features, feature) } - return features, nil + return result, nil } diff --git a/internals/features/parser_test.go b/internals/features/parser_test.go index 5cd19df..ff618d3 100644 --- a/internals/features/parser_test.go +++ b/internals/features/parser_test.go @@ -35,17 +35,7 @@ func TestParseFeatureFile(t *testing.T) { assert.Equal(t, "test-feature", feature.Name) assert.Equal(t, "Install Test Feature", feature.Description) - expectedVars := []string{"gpg", "repo"} - assert.Equal(t, len(expectedVars), len(feature.Vars)) - - varMap := make(map[string]bool) - for _, v := range feature.Vars { - varMap[v] = true - } - - for _, expected := range expectedVars { - assert.Assert(t, varMap[expected]) - } + assert.DeepEqual(t, []string{"gpg", "repo"}, feature.Vars) } func TestParseFeatureFileNoVars(t *testing.T) { @@ -73,6 +63,17 @@ func TestParseFeatureFileNoVars(t *testing.T) { assert.Equal(t, 0, len(feature.Vars)) } +func TestParseFeatureFileEmptyArray(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "empty.yaml") + + err := os.WriteFile(testFile, []byte("---\n[]\n"), 0644) + assert.NilError(t, err) + + _, err = ParseFeatureFile(testFile) + assert.ErrorContains(t, err, "no playbook tasks found") +} + func TestParseFeatureFileInvalid(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "invalid.yaml") @@ -111,12 +112,13 @@ func TestListFeatures(t *testing.T) { assert.NilError(t, err) } - features, err := ListFeatures(tmpDir) + result, err := ListFeatures(tmpDir) assert.NilError(t, err) - assert.Equal(t, 2, len(features)) + assert.Equal(t, 2, len(result.Features)) + assert.Equal(t, 0, len(result.Warnings)) featureNames := make(map[string]bool) - for _, feature := range features { + for _, feature := range result.Features { featureNames[feature.Name] = true } @@ -124,6 +126,31 @@ func TestListFeatures(t *testing.T) { assert.Assert(t, featureNames["feature2"]) } +func TestListFeaturesWithWarnings(t *testing.T) { + tmpDir := t.TempDir() + + testFiles := map[string]string{ + "good.yaml": `--- +- name: Good Feature + gather_facts: false + hosts: workspace +`, + "bad.yaml": "invalid yaml [[[", + } + + for filename, content := range testFiles { + err := os.WriteFile(filepath.Join(tmpDir, filename), []byte(content), 0644) + assert.NilError(t, err) + } + + result, err := ListFeatures(tmpDir) + assert.NilError(t, err) + assert.Equal(t, 1, len(result.Features)) + assert.Equal(t, "good", result.Features[0].Name) + assert.Equal(t, 1, len(result.Warnings)) + assert.Assert(t, len(result.Warnings[0]) > 0) +} + func TestInfoFeature(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test-feature.yaml") @@ -150,15 +177,7 @@ func TestInfoFeature(t *testing.T) { assert.Equal(t, "test-feature", feature.Name) assert.Equal(t, "Install Test Feature", feature.Description) - assert.Equal(t, 2, len(feature.Vars)) - - varMap := make(map[string]bool) - for _, v := range feature.Vars { - varMap[v] = true - } - - assert.Assert(t, varMap["option1"]) - assert.Assert(t, varMap["option2"]) + assert.DeepEqual(t, []string{"option1", "option2"}, feature.Vars) } func TestInfoFeatureNotFound(t *testing.T) { diff --git a/internals/features/store.go b/internals/features/store.go new file mode 100644 index 0000000..871073f --- /dev/null +++ b/internals/features/store.go @@ -0,0 +1,46 @@ +package features + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +type StoreManifest struct { + Built string `json:"built"` + Artifacts []StoreArtifact `json:"artifacts"` +} + +type StoreArtifact struct { + Name string `json:"name"` + Version string `json:"version"` + Files []string `json:"files"` +} + +func FetchStoreManifest(baseURL string) (*StoreManifest, error) { + manifestURL, err := url.JoinPath(baseURL, "artifacts", "manifest.json") + if err != nil { + return nil, fmt.Errorf("invalid store URL: %w", err) + } + + client := &http.Client{Timeout: 5 * time.Second} + + resp, err := client.Get(manifestURL) + if err != nil { + return nil, fmt.Errorf("failed to reach feature store: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("feature store returned status %d", resp.StatusCode) + } + + var manifest StoreManifest + if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + return nil, fmt.Errorf("failed to parse manifest: %w", err) + } + + return &manifest, nil +} diff --git a/internals/metrics/collectors.go b/internals/metrics/collectors.go new file mode 100644 index 0000000..da22a11 --- /dev/null +++ b/internals/metrics/collectors.go @@ -0,0 +1,95 @@ +package metrics + +import ( + "slices" + "strings" +) + +const Namespace = "workspace" + +var ValidCollectors = map[string][]string{ + "workspace": {"workspace.info", "workspace.session", "workspace.extensions"}, + "workspace.info": {}, + "workspace.session": {}, + "workspace.extensions": {}, + "container": {"container.cpu", "container.memory", "container.fs", "container.fd", "container.pids"}, + "container.cpu": {}, + "container.memory": {}, + "container.fs": {}, + "container.fd": {}, + "container.pids": {}, + "pressure": {"pressure.cpu", "pressure.memory", "pressure.io"}, + "pressure.cpu": {}, + "pressure.memory": {}, + "pressure.io": {}, + "network": {}, + "io": {}, + "sockets": {}, + "gpu": {}, +} + +var allLeafCollectors = []string{ + "container.cpu", + "container.fd", + "container.fs", + "container.memory", + "container.pids", + "io", + "network", + "pressure.cpu", + "pressure.io", + "pressure.memory", + "sockets", + "workspace.extensions", + "workspace.info", + "workspace.session", +} + +func IsCollectorEnabled(name string, collectors []string) bool { + if len(collectors) == 0 || slices.Contains(collectors, "*") { + return true + } + + for _, c := range collectors { + if c == name || strings.HasPrefix(name, c+".") || strings.HasPrefix(c, name+".") { + return true + } + } + + return false +} + +func ExpandCollectors(collectors []string) []string { + if len(collectors) == 0 || slices.Contains(collectors, "*") { + result := make([]string, 0, len(allLeafCollectors)+2) + for _, c := range allLeafCollectors { + if strings.HasPrefix(c, "pressure.") && !IsPressureAvailable() { + continue + } + result = append(result, c) + } + if IsGPUAvailable() { + result = append(result, "gpu") + } + return result + } + + expanded := make(map[string]bool) + for _, c := range collectors { + subs := ValidCollectors[c] + if len(subs) == 0 { + expanded[c] = true + continue + } + for _, sub := range subs { + expanded[sub] = true + } + } + + result := make([]string, 0, len(expanded)) + for c := range expanded { + result = append(result, c) + } + slices.Sort(result) + return result +} diff --git a/internals/metrics/prometheus.go b/internals/metrics/prometheus.go new file mode 100644 index 0000000..a0ce013 --- /dev/null +++ b/internals/metrics/prometheus.go @@ -0,0 +1,469 @@ +package metrics + +import ( + "github.com/kloudkit/ws-cli/internals/config" + "github.com/prometheus/client_golang/prometheus" +) + +func newDesc(subsystem, name, description string) *prometheus.Desc { + return prometheus.NewDesc( + prometheus.BuildFQName(Namespace, subsystem, name), + description, + nil, nil, + ) +} + +type WorkspaceCollector struct { + info *prometheus.Desc + initializedTimestamp *prometheus.Desc + uptimeSeconds *prometheus.Desc + extensionsInstalledTotal *prometheus.Desc + infoLabels prometheus.Labels + initializedUnix float64 + enabled []string +} + +func NewWorkspaceCollector(enabled []string) *WorkspaceCollector { + c := &WorkspaceCollector{ + info: prometheus.NewDesc( + prometheus.BuildFQName(Namespace, "", "info"), + "Workspace build information", + []string{"version", "vscode_version"}, + nil, + ), + initializedTimestamp: newDesc("session", "initialized_timestamp_seconds", "Unix timestamp when workspace was initialized"), + uptimeSeconds: newDesc("session", "uptime_seconds", "Seconds since workspace was initialized"), + extensionsInstalledTotal: newDesc("", "extensions_installed_total", "Number of VS Code extensions installed"), + infoLabels: prometheus.Labels{"version": "", "vscode_version": ""}, + enabled: enabled, + } + + if manifest, err := config.ReadManifest(); err == nil { + c.infoLabels = prometheus.Labels{ + "version": manifest.Version, + "vscode_version": manifest.VSCode.Version, + } + } + + if initialized, err := config.GetInitializedTime(); err == nil { + c.initializedUnix = float64(initialized.Unix()) + } + + return c +} + +func (c *WorkspaceCollector) has(name string) bool { + return IsCollectorEnabled(name, c.enabled) +} + +func (c *WorkspaceCollector) Describe(ch chan<- *prometheus.Desc) { + if c.has("workspace.info") { + ch <- c.info + } + if c.has("workspace.session") { + ch <- c.initializedTimestamp + ch <- c.uptimeSeconds + } + if c.has("workspace.extensions") { + ch <- c.extensionsInstalledTotal + } +} + +func (c *WorkspaceCollector) Collect(ch chan<- prometheus.Metric) { + if c.has("workspace.info") { + ch <- prometheus.MustNewConstMetric( + c.info, prometheus.GaugeValue, 1, + c.infoLabels["version"], c.infoLabels["vscode_version"], + ) + } + + if c.has("workspace.session") { + ch <- prometheus.MustNewConstMetric(c.initializedTimestamp, prometheus.GaugeValue, c.initializedUnix) + if uptime, err := config.GetUptime(); err == nil { + ch <- prometheus.MustNewConstMetric(c.uptimeSeconds, prometheus.GaugeValue, uptime.Seconds()) + } + } + + if c.has("workspace.extensions") { + if count, err := config.GetExtensionCount(); err == nil { + ch <- prometheus.MustNewConstMetric(c.extensionsInstalledTotal, prometheus.GaugeValue, float64(count)) + } + } +} + +type ContainerCollector struct { + cpuUsageSeconds *prometheus.Desc + cpuUserSeconds *prometheus.Desc + cpuSystemSeconds *prometheus.Desc + cpuThrottledPeriods *prometheus.Desc + cpuThrottledSeconds *prometheus.Desc + cpuPeriodsTotal *prometheus.Desc + memUsageBytes *prometheus.Desc + memLimitBytes *prometheus.Desc + memRSSBytes *prometheus.Desc + memCacheBytes *prometheus.Desc + memSwapBytes *prometheus.Desc + memSwapLimitBytes *prometheus.Desc + memAnonBytes *prometheus.Desc + memKernelBytes *prometheus.Desc + memSlabBytes *prometheus.Desc + memOOMTotal *prometheus.Desc + memOOMKillTotal *prometheus.Desc + memMaxTotal *prometheus.Desc + fsUsageBytes *prometheus.Desc + fsLimitBytes *prometheus.Desc + fdOpen *prometheus.Desc + fdLimit *prometheus.Desc + pidsCurrent *prometheus.Desc + pidsLimit *prometheus.Desc + enabled []string +} + +func NewContainerCollector(enabled []string) *ContainerCollector { + desc := func(name, description string) *prometheus.Desc { + return newDesc("container", name, description) + } + return &ContainerCollector{ + cpuUsageSeconds: desc("cpu_usage_seconds_total", "Total CPU time consumed by the container"), + cpuUserSeconds: desc("cpu_user_seconds_total", "CPU time consumed in user mode"), + cpuSystemSeconds: desc("cpu_system_seconds_total", "CPU time consumed in system mode"), + cpuThrottledPeriods: desc("cpu_throttled_periods_total", "Number of throttled CPU periods"), + cpuThrottledSeconds: desc("cpu_throttled_seconds_total", "Total time throttled in seconds"), + cpuPeriodsTotal: desc("cpu_periods_total", "Total number of CPU scheduling periods"), + memUsageBytes: desc("memory_usage_bytes", "Current memory usage in bytes"), + memLimitBytes: desc("memory_limit_bytes", "Memory limit in bytes"), + memRSSBytes: desc("memory_rss_bytes", "Resident set size in bytes"), + memCacheBytes: desc("memory_cache_bytes", "Page cache memory in bytes"), + memSwapBytes: desc("memory_swap_bytes", "Swap usage in bytes"), + memSwapLimitBytes: desc("memory_swap_limit_bytes", "Swap limit in bytes"), + memAnonBytes: desc("memory_anon_bytes", "Anonymous memory in bytes"), + memKernelBytes: desc("memory_kernel_bytes", "Kernel memory in bytes"), + memSlabBytes: desc("memory_slab_bytes", "Slab allocator memory in bytes"), + memOOMTotal: desc("memory_oom_total", "Number of OOM events"), + memOOMKillTotal: desc("memory_oom_kill_total", "Number of OOM kill events"), + memMaxTotal: desc("memory_max_total", "Number of times memory limit was hit"), + fsUsageBytes: desc("fs_usage_bytes", "Filesystem usage in bytes on /workspace"), + fsLimitBytes: desc("fs_limit_bytes", "Filesystem capacity in bytes on /workspace"), + fdOpen: desc("file_descriptors_open", "Number of open file descriptors"), + fdLimit: desc("file_descriptors_limit", "File descriptor limit"), + pidsCurrent: desc("pids_current", "Current number of processes"), + pidsLimit: desc("pids_limit", "Process limit"), + enabled: enabled, + } +} + +func (c *ContainerCollector) has(name string) bool { + return IsCollectorEnabled(name, c.enabled) +} + +func (c *ContainerCollector) Describe(ch chan<- *prometheus.Desc) { + if c.has("container.cpu") { + ch <- c.cpuUsageSeconds + ch <- c.cpuUserSeconds + ch <- c.cpuSystemSeconds + ch <- c.cpuThrottledPeriods + ch <- c.cpuThrottledSeconds + ch <- c.cpuPeriodsTotal + } + if c.has("container.memory") { + ch <- c.memUsageBytes + ch <- c.memLimitBytes + ch <- c.memRSSBytes + ch <- c.memCacheBytes + ch <- c.memSwapBytes + ch <- c.memSwapLimitBytes + ch <- c.memAnonBytes + ch <- c.memKernelBytes + ch <- c.memSlabBytes + ch <- c.memOOMTotal + ch <- c.memOOMKillTotal + ch <- c.memMaxTotal + } + if c.has("container.fs") { + ch <- c.fsUsageBytes + ch <- c.fsLimitBytes + } + if c.has("container.fd") { + ch <- c.fdOpen + ch <- c.fdLimit + } + if c.has("container.pids") { + ch <- c.pidsCurrent + ch <- c.pidsLimit + } +} + +func (c *ContainerCollector) Collect(ch chan<- prometheus.Metric) { + if c.has("container.cpu") { + if cpu, err := GetCPUStats(); err == nil { + ch <- prometheus.MustNewConstMetric(c.cpuUsageSeconds, prometheus.CounterValue, cpu.UsageSeconds) + ch <- prometheus.MustNewConstMetric(c.cpuUserSeconds, prometheus.CounterValue, cpu.UserSeconds) + ch <- prometheus.MustNewConstMetric(c.cpuSystemSeconds, prometheus.CounterValue, cpu.SystemSeconds) + ch <- prometheus.MustNewConstMetric(c.cpuThrottledPeriods, prometheus.CounterValue, float64(cpu.ThrottledPeriods)) + ch <- prometheus.MustNewConstMetric(c.cpuThrottledSeconds, prometheus.CounterValue, cpu.ThrottledSeconds) + ch <- prometheus.MustNewConstMetric(c.cpuPeriodsTotal, prometheus.CounterValue, float64(cpu.TotalPeriods)) + } + } + + if c.has("container.memory") { + if mem, err := GetMemoryStats(); err == nil { + ch <- prometheus.MustNewConstMetric(c.memUsageBytes, prometheus.GaugeValue, float64(mem.UsageBytes)) + ch <- prometheus.MustNewConstMetric(c.memLimitBytes, prometheus.GaugeValue, float64(mem.LimitBytes)) + ch <- prometheus.MustNewConstMetric(c.memRSSBytes, prometheus.GaugeValue, float64(mem.RSSBytes)) + ch <- prometheus.MustNewConstMetric(c.memCacheBytes, prometheus.GaugeValue, float64(mem.CacheBytes)) + ch <- prometheus.MustNewConstMetric(c.memSwapBytes, prometheus.GaugeValue, float64(mem.SwapBytes)) + ch <- prometheus.MustNewConstMetric(c.memSwapLimitBytes, prometheus.GaugeValue, float64(mem.SwapLimitBytes)) + ch <- prometheus.MustNewConstMetric(c.memAnonBytes, prometheus.GaugeValue, float64(mem.AnonBytes)) + ch <- prometheus.MustNewConstMetric(c.memKernelBytes, prometheus.GaugeValue, float64(mem.KernelBytes)) + ch <- prometheus.MustNewConstMetric(c.memSlabBytes, prometheus.GaugeValue, float64(mem.SlabBytes)) + ch <- prometheus.MustNewConstMetric(c.memOOMTotal, prometheus.CounterValue, float64(mem.OOMEvents)) + ch <- prometheus.MustNewConstMetric(c.memOOMKillTotal, prometheus.CounterValue, float64(mem.OOMKillEvents)) + ch <- prometheus.MustNewConstMetric(c.memMaxTotal, prometheus.CounterValue, float64(mem.MaxEvents)) + } + } + + if c.has("container.fs") { + if disk, err := GetDiskStats(); err == nil { + ch <- prometheus.MustNewConstMetric(c.fsUsageBytes, prometheus.GaugeValue, float64(disk.UsageBytes)) + ch <- prometheus.MustNewConstMetric(c.fsLimitBytes, prometheus.GaugeValue, float64(disk.LimitBytes)) + } + } + + if c.has("container.fd") { + if fd, err := GetFileDescriptorStats(); err == nil { + ch <- prometheus.MustNewConstMetric(c.fdOpen, prometheus.GaugeValue, float64(fd.Open)) + ch <- prometheus.MustNewConstMetric(c.fdLimit, prometheus.GaugeValue, float64(fd.Limit)) + } + } + + if c.has("container.pids") { + if pids, err := GetPIDStats(); err == nil { + ch <- prometheus.MustNewConstMetric(c.pidsCurrent, prometheus.GaugeValue, float64(pids.Current)) + ch <- prometheus.MustNewConstMetric(c.pidsLimit, prometheus.GaugeValue, float64(pids.Limit)) + } + } +} + +type GPUCollector struct { + utilizationRatio *prometheus.Desc + memoryUsedBytes *prometheus.Desc + memoryTotalBytes *prometheus.Desc + temperatureCelsius *prometheus.Desc + powerWatts *prometheus.Desc +} + +func NewGPUCollector() *GPUCollector { + desc := func(name, description string) *prometheus.Desc { + return newDesc("gpu", name, description) + } + return &GPUCollector{ + utilizationRatio: desc("utilization_ratio", "GPU utilization ratio (0-1)"), + memoryUsedBytes: desc("memory_used_bytes", "GPU memory used in bytes"), + memoryTotalBytes: desc("memory_total_bytes", "GPU memory total in bytes"), + temperatureCelsius: desc("temperature_celsius", "GPU temperature in Celsius"), + powerWatts: desc("power_watts", "GPU power consumption in watts"), + } +} + +func (c *GPUCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.utilizationRatio + ch <- c.memoryUsedBytes + ch <- c.memoryTotalBytes + ch <- c.temperatureCelsius + ch <- c.powerWatts +} + +func (c *GPUCollector) Collect(ch chan<- prometheus.Metric) { + stats, err := GetGPUStats() + if err != nil || !stats.Available { + return + } + + ch <- prometheus.MustNewConstMetric(c.utilizationRatio, prometheus.GaugeValue, stats.UtilizationRatio) + ch <- prometheus.MustNewConstMetric(c.memoryUsedBytes, prometheus.GaugeValue, float64(stats.MemoryUsedBytes)) + ch <- prometheus.MustNewConstMetric(c.memoryTotalBytes, prometheus.GaugeValue, float64(stats.MemoryTotalBytes)) + ch <- prometheus.MustNewConstMetric(c.temperatureCelsius, prometheus.GaugeValue, stats.TemperatureCelsius) + ch <- prometheus.MustNewConstMetric(c.powerWatts, prometheus.GaugeValue, stats.PowerWatts) +} + +type PressureCollector struct { + cpuWaitingSeconds *prometheus.Desc + cpuStalledSeconds *prometheus.Desc + memoryWaitingSeconds *prometheus.Desc + memoryStalledSeconds *prometheus.Desc + ioWaitingSeconds *prometheus.Desc + ioStalledSeconds *prometheus.Desc + enabled []string +} + +func NewPressureCollector(enabled []string) *PressureCollector { + desc := func(name, description string) *prometheus.Desc { + return newDesc("pressure", name, description) + } + return &PressureCollector{ + cpuWaitingSeconds: desc("cpu_waiting_seconds_total", "Total time tasks waited for CPU"), + cpuStalledSeconds: desc("cpu_stalled_seconds_total", "Total time all tasks were stalled on CPU"), + memoryWaitingSeconds: desc("memory_waiting_seconds_total", "Total time tasks waited for memory"), + memoryStalledSeconds: desc("memory_stalled_seconds_total", "Total time all tasks were stalled on memory"), + ioWaitingSeconds: desc("io_waiting_seconds_total", "Total time tasks waited for I/O"), + ioStalledSeconds: desc("io_stalled_seconds_total", "Total time all tasks were stalled on I/O"), + enabled: enabled, + } +} + +func (c *PressureCollector) has(name string) bool { + return IsCollectorEnabled(name, c.enabled) +} + +func (c *PressureCollector) Describe(ch chan<- *prometheus.Desc) { + if c.has("pressure.cpu") { + ch <- c.cpuWaitingSeconds + ch <- c.cpuStalledSeconds + } + if c.has("pressure.memory") { + ch <- c.memoryWaitingSeconds + ch <- c.memoryStalledSeconds + } + if c.has("pressure.io") { + ch <- c.ioWaitingSeconds + ch <- c.ioStalledSeconds + } +} + +func (c *PressureCollector) Collect(ch chan<- prometheus.Metric) { + stats, err := GetPressureStats() + if err != nil { + return + } + + if c.has("pressure.cpu") { + ch <- prometheus.MustNewConstMetric(c.cpuWaitingSeconds, prometheus.CounterValue, stats.CPUWaitingSeconds) + ch <- prometheus.MustNewConstMetric(c.cpuStalledSeconds, prometheus.CounterValue, stats.CPUStalledSeconds) + } + if c.has("pressure.memory") { + ch <- prometheus.MustNewConstMetric(c.memoryWaitingSeconds, prometheus.CounterValue, stats.MemoryWaitingSeconds) + ch <- prometheus.MustNewConstMetric(c.memoryStalledSeconds, prometheus.CounterValue, stats.MemoryStalledSeconds) + } + if c.has("pressure.io") { + ch <- prometheus.MustNewConstMetric(c.ioWaitingSeconds, prometheus.CounterValue, stats.IOWaitingSeconds) + ch <- prometheus.MustNewConstMetric(c.ioStalledSeconds, prometheus.CounterValue, stats.IOStalledSeconds) + } +} + +type NetworkCollector struct { + receiveBytesTotal *prometheus.Desc + transmitBytesTotal *prometheus.Desc + receivePacketsTotal *prometheus.Desc + transmitPacketsTotal *prometheus.Desc + receiveErrorsTotal *prometheus.Desc + transmitErrorsTotal *prometheus.Desc +} + +func NewNetworkCollector() *NetworkCollector { + desc := func(name, description string) *prometheus.Desc { + return newDesc("network", name, description) + } + return &NetworkCollector{ + receiveBytesTotal: desc("receive_bytes_total", "Total bytes received"), + transmitBytesTotal: desc("transmit_bytes_total", "Total bytes transmitted"), + receivePacketsTotal: desc("receive_packets_total", "Total packets received"), + transmitPacketsTotal: desc("transmit_packets_total", "Total packets transmitted"), + receiveErrorsTotal: desc("receive_errors_total", "Total receive errors"), + transmitErrorsTotal: desc("transmit_errors_total", "Total transmit errors"), + } +} + +func (c *NetworkCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.receiveBytesTotal + ch <- c.transmitBytesTotal + ch <- c.receivePacketsTotal + ch <- c.transmitPacketsTotal + ch <- c.receiveErrorsTotal + ch <- c.transmitErrorsTotal +} + +func (c *NetworkCollector) Collect(ch chan<- prometheus.Metric) { + stats, err := GetNetworkStats() + if err != nil { + return + } + + ch <- prometheus.MustNewConstMetric(c.receiveBytesTotal, prometheus.CounterValue, float64(stats.ReceiveBytesTotal)) + ch <- prometheus.MustNewConstMetric(c.transmitBytesTotal, prometheus.CounterValue, float64(stats.TransmitBytesTotal)) + ch <- prometheus.MustNewConstMetric(c.receivePacketsTotal, prometheus.CounterValue, float64(stats.ReceivePacketsTotal)) + ch <- prometheus.MustNewConstMetric(c.transmitPacketsTotal, prometheus.CounterValue, float64(stats.TransmitPacketsTotal)) + ch <- prometheus.MustNewConstMetric(c.receiveErrorsTotal, prometheus.CounterValue, float64(stats.ReceiveErrorsTotal)) + ch <- prometheus.MustNewConstMetric(c.transmitErrorsTotal, prometheus.CounterValue, float64(stats.TransmitErrorsTotal)) +} + +type IOCollector struct { + readBytesTotal *prometheus.Desc + writeBytesTotal *prometheus.Desc + readOpsTotal *prometheus.Desc + writeOpsTotal *prometheus.Desc +} + +func NewIOCollector() *IOCollector { + desc := func(name, description string) *prometheus.Desc { + return newDesc("io", name, description) + } + return &IOCollector{ + readBytesTotal: desc("read_bytes_total", "Total bytes read from disk"), + writeBytesTotal: desc("write_bytes_total", "Total bytes written to disk"), + readOpsTotal: desc("read_ops_total", "Total disk read operations"), + writeOpsTotal: desc("write_ops_total", "Total disk write operations"), + } +} + +func (c *IOCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.readBytesTotal + ch <- c.writeBytesTotal + ch <- c.readOpsTotal + ch <- c.writeOpsTotal +} + +func (c *IOCollector) Collect(ch chan<- prometheus.Metric) { + stats, err := GetIOStats() + if err != nil { + return + } + + ch <- prometheus.MustNewConstMetric(c.readBytesTotal, prometheus.CounterValue, float64(stats.ReadBytesTotal)) + ch <- prometheus.MustNewConstMetric(c.writeBytesTotal, prometheus.CounterValue, float64(stats.WriteBytesTotal)) + ch <- prometheus.MustNewConstMetric(c.readOpsTotal, prometheus.CounterValue, float64(stats.ReadOpsTotal)) + ch <- prometheus.MustNewConstMetric(c.writeOpsTotal, prometheus.CounterValue, float64(stats.WriteOpsTotal)) +} + +type SocketsCollector struct { + tcpEstablished *prometheus.Desc + tcpListen *prometheus.Desc + udp *prometheus.Desc +} + +func NewSocketsCollector() *SocketsCollector { + desc := func(name, description string) *prometheus.Desc { + return newDesc("sockets", name, description) + } + return &SocketsCollector{ + tcpEstablished: desc("tcp_established", "Number of established TCP connections"), + tcpListen: desc("tcp_listen", "Number of listening TCP sockets"), + udp: desc("udp", "Number of UDP sockets"), + } +} + +func (c *SocketsCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.tcpEstablished + ch <- c.tcpListen + ch <- c.udp +} + +func (c *SocketsCollector) Collect(ch chan<- prometheus.Metric) { + stats, err := GetSocketStats() + if err != nil { + return + } + + ch <- prometheus.MustNewConstMetric(c.tcpEstablished, prometheus.GaugeValue, float64(stats.TCPEstablished)) + ch <- prometheus.MustNewConstMetric(c.tcpListen, prometheus.GaugeValue, float64(stats.TCPListen)) + ch <- prometheus.MustNewConstMetric(c.udp, prometheus.GaugeValue, float64(stats.UDP)) +} diff --git a/internals/metrics/serve.go b/internals/metrics/serve.go new file mode 100644 index 0000000..09b9eea --- /dev/null +++ b/internals/metrics/serve.go @@ -0,0 +1,119 @@ +package metrics + +import ( + "errors" + "slices" + "strconv" + "strings" + + "github.com/kloudkit/ws-cli/internals/config" + "github.com/kloudkit/ws-cli/internals/env" + "github.com/prometheus/client_golang/prometheus" +) + +type RegistryResult struct { + Registry *prometheus.Registry + Expanded []string + Invalid []string + Warnings []string +} + +func BuildRegistry(collectors []string) (*RegistryResult, error) { + result := &RegistryResult{} + + var validated []string + hasExplicit := len(collectors) > 0 + + for _, c := range collectors { + if c = strings.TrimSpace(c); c == "" { + continue + } + if c == "*" { + validated = []string{"*"} + break + } + if _, ok := ValidCollectors[c]; ok { + validated = append(validated, c) + } else { + result.Invalid = append(result.Invalid, c) + } + } + + hasWorkspace := IsCollectorEnabled("workspace", validated) + hasContainer := IsCollectorEnabled("container", validated) + hasPressure := IsCollectorEnabled("pressure", validated) && IsPressureAvailable() + hasNetwork := IsCollectorEnabled("network", validated) + hasIO := IsCollectorEnabled("io", validated) + hasSockets := IsCollectorEnabled("sockets", validated) + gpuRequested := slices.Contains(validated, "gpu") + hasGPU := IsCollectorEnabled("gpu", validated) && IsGPUAvailable() + + pressureRequested := slices.Contains(validated, "pressure") || slices.Contains(validated, "pressure.cpu") || slices.Contains(validated, "pressure.memory") || slices.Contains(validated, "pressure.io") + if pressureRequested && !IsPressureAvailable() { + result.Warnings = append(result.Warnings, "PSI pressure metrics not available (cgroup v2 only), skipping pressure collector") + validated = slices.DeleteFunc(validated, func(c string) bool { + return c == "pressure" || strings.HasPrefix(c, "pressure.") + }) + hasPressure = false + } + + if gpuRequested && !hasGPU { + result.Warnings = append(result.Warnings, "GPU not available, skipping gpu collector") + validated = slices.DeleteFunc(validated, func(c string) bool { return c == "gpu" }) + } + + if hasExplicit && !hasWorkspace && !hasContainer && !hasPressure && !hasNetwork && !hasIO && !hasSockets && !hasGPU { + return nil, errors.New("no collectors enabled") + } + + registry := prometheus.NewRegistry() + if hasWorkspace { + registry.MustRegister(NewWorkspaceCollector(validated)) + } + if hasContainer { + registry.MustRegister(NewContainerCollector(validated)) + } + if hasPressure { + registry.MustRegister(NewPressureCollector(validated)) + } + if hasNetwork { + registry.MustRegister(NewNetworkCollector()) + } + if hasIO { + registry.MustRegister(NewIOCollector()) + } + if hasSockets { + registry.MustRegister(NewSocketsCollector()) + } + if hasGPU { + registry.MustRegister(NewGPUCollector()) + } + + result.Registry = registry + result.Expanded = ExpandCollectors(validated) + return result, nil +} + +func DefaultPort() int { + if portStr := env.String(config.EnvMetricsPort); portStr != "" { + if port, err := strconv.Atoi(portStr); err == nil { + return port + } + } + return config.DefaultMetricsPort +} + +func DefaultCollectors() []string { + envCollectors := env.String(config.EnvMetricsCollectors) + if envCollectors == "" { + return nil + } + + var collectors []string + for _, c := range strings.Split(envCollectors, ",") { + if c = strings.TrimSpace(c); c != "" { + collectors = append(collectors, c) + } + } + return collectors +} diff --git a/internals/metrics/summary.go b/internals/metrics/summary.go new file mode 100644 index 0000000..7e26d56 --- /dev/null +++ b/internals/metrics/summary.go @@ -0,0 +1,60 @@ +package metrics + +import "fmt" + +type WorkspaceSummary struct { + CPUUsage float64 + CPUSeconds float64 + MemoryTotal uint64 + MemoryUsed uint64 + MemoryRSS uint64 + DiskTotal uint64 + DiskUsed uint64 + FDOpen uint64 + FDLimit uint64 + GPU *GPUStats +} + +func GetWorkspaceSummary(includeGPU bool) (*WorkspaceSummary, error) { + cpuUsage, _ := GetCPUUsagePercent() + + cpuStats, err := GetCPUStats() + if err != nil { + return nil, fmt.Errorf("failed to get CPU stats: %w", err) + } + + memStats, err := GetMemoryStats() + if err != nil { + return nil, fmt.Errorf("failed to get memory stats: %w", err) + } + + diskStats, err := GetDiskStats() + if err != nil { + return nil, fmt.Errorf("failed to get disk stats: %w", err) + } + + fdStats, err := GetFileDescriptorStats() + if err != nil { + return nil, fmt.Errorf("failed to get file descriptor stats: %w", err) + } + + m := &WorkspaceSummary{ + CPUUsage: cpuUsage, + CPUSeconds: cpuStats.UsageSeconds, + MemoryTotal: memStats.LimitBytes, + MemoryUsed: memStats.UsageBytes, + MemoryRSS: memStats.RSSBytes, + DiskTotal: diskStats.LimitBytes, + DiskUsed: diskStats.UsageBytes, + FDOpen: fdStats.Open, + FDLimit: fdStats.Limit, + } + + if includeGPU { + if gpuStats, err := GetGPUStats(); err == nil { + m.GPU = gpuStats + } + } + + return m, nil +} diff --git a/internals/secrets/vault.go b/internals/secrets/vault.go index e0e7f49..ba7e6d1 100644 --- a/internals/secrets/vault.go +++ b/internals/secrets/vault.go @@ -323,3 +323,87 @@ func FormatSecretForStdout(key string, value string, raw bool) string { return fmt.Sprintf("[%s]\n%s\n", key, value) } + +type VaultEntry struct { + Name string + Type string + Destination string +} + +func ListVault(vault *Vault) []VaultEntry { + entries := make([]VaultEntry, 0, len(vault.Secrets)) + for name, secret := range vault.Secrets { + entries = append(entries, VaultEntry{ + Name: name, + Type: secret.Type, + Destination: secret.Destination, + }) + } + slices.SortFunc(entries, func(a, b VaultEntry) int { + return strings.Compare(a.Name, b.Name) + }) + return entries +} + +func LoadRawVault(path string) (*Vault, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read vault file %q: %w", path, err) + } + + var vault Vault + if err := yaml.Unmarshal(data, &vault); err != nil { + return nil, fmt.Errorf("failed to unmarshal vault yaml: %w", err) + } + + if vault.Secrets == nil { + vault.Secrets = make(map[string]VaultSecret) + } + + return &vault, nil +} + +func RotateVault(vault *Vault, oldKey, newKey []byte) ([]string, error) { + var fileRefs []string + + for name, secret := range vault.Secrets { + encryptedValue := secret.Encrypted + if strings.HasPrefix(encryptedValue, "file:") { + fileRefs = append(fileRefs, name) + } + + resolved, err := ResolveEncryptedValue(encryptedValue) + if err != nil { + return nil, fmt.Errorf("secret %q: %w", name, err) + } + + decrypted, err := Decrypt(resolved, oldKey) + if err != nil { + return nil, fmt.Errorf("secret %q: failed to decrypt: %w", name, err) + } + + reEncrypted, err := Encrypt(decrypted, newKey) + if err != nil { + return nil, fmt.Errorf("secret %q: failed to re-encrypt: %w", name, err) + } + + secret.Encrypted = reEncrypted + vault.Secrets[name] = secret + } + + slices.Sort(fileRefs) + return fileRefs, nil +} + +func SaveVault(path string, vault *Vault) error { + data, err := yaml.Marshal(vault) + if err != nil { + return fmt.Errorf("failed to marshal vault yaml: %w", err) + } + + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("failed to write vault file %q: %w", path, err) + } + + return nil +} diff --git a/internals/secrets/vault_test.go b/internals/secrets/vault_test.go index 3ad072d..7d7d4ab 100644 --- a/internals/secrets/vault_test.go +++ b/internals/secrets/vault_test.go @@ -634,3 +634,252 @@ export VAR2="old2" assert.Assert(t, strings.Contains(contentStr, `export VAR2="old2"`)) }) } + +func TestListVault(t *testing.T) { + t.Run("SortedOutput", func(t *testing.T) { + vault := &Vault{ + Secrets: map[string]VaultSecret{ + "zebra": {Type: TypeSSH, Destination: "/z", Encrypted: "enc"}, + "alpha": {Type: TypeGeneric, Destination: "/a", Encrypted: "enc"}, + "mike": {Type: TypeEnv, Destination: "MY_VAR", Encrypted: "enc"}, + }, + } + + entries := ListVault(vault) + assert.Equal(t, 3, len(entries)) + assert.Equal(t, "alpha", entries[0].Name) + assert.Equal(t, "mike", entries[1].Name) + assert.Equal(t, "zebra", entries[2].Name) + }) + + t.Run("EmptyVault", func(t *testing.T) { + vault := &Vault{Secrets: map[string]VaultSecret{}} + entries := ListVault(vault) + assert.Equal(t, 0, len(entries)) + }) + + t.Run("PreservesTypeAndDestination", func(t *testing.T) { + vault := &Vault{ + Secrets: map[string]VaultSecret{ + "ssh_key": {Type: TypeSSH, Destination: "/home/.ssh/id_rsa", Encrypted: "enc"}, + }, + } + + entries := ListVault(vault) + assert.Equal(t, 1, len(entries)) + assert.Equal(t, TypeSSH, entries[0].Type) + assert.Equal(t, "/home/.ssh/id_rsa", entries[0].Destination) + }) +} + +func TestLoadRawVault(t *testing.T) { + t.Run("DoesNotResolveDestinations", func(t *testing.T) { + vaultContent := ` +secrets: + ssh_key: + type: "ssh" + encrypted: "test$encrypted" + destination: "github.com/id_ed25519" +` + vaultFile := filepath.Join(t.TempDir(), "vault.yaml") + err := os.WriteFile(vaultFile, []byte(vaultContent), 0600) + assert.NilError(t, err) + + vault, err := LoadRawVault(vaultFile) + assert.NilError(t, err) + assert.Equal(t, "github.com/id_ed25519", vault.Secrets["ssh_key"].Destination) + }) + + t.Run("DoesNotFillDefaults", func(t *testing.T) { + vaultContent := ` +secrets: + db_password: + encrypted: "test$encrypted" + destination: "/etc/db/password" +` + vaultFile := filepath.Join(t.TempDir(), "vault.yaml") + err := os.WriteFile(vaultFile, []byte(vaultContent), 0600) + assert.NilError(t, err) + + vault, err := LoadRawVault(vaultFile) + assert.NilError(t, err) + assert.Equal(t, "", vault.Secrets["db_password"].Type) + assert.Equal(t, "", vault.Secrets["db_password"].Mode) + }) + + t.Run("EmptyVault", func(t *testing.T) { + vaultContent := `secrets: {}` + vaultFile := filepath.Join(t.TempDir(), "vault.yaml") + err := os.WriteFile(vaultFile, []byte(vaultContent), 0600) + assert.NilError(t, err) + + vault, err := LoadRawVault(vaultFile) + assert.NilError(t, err) + assert.Equal(t, 0, len(vault.Secrets)) + }) + + t.Run("FileNotFound", func(t *testing.T) { + _, err := LoadRawVault("/nonexistent/vault.yaml") + assert.ErrorContains(t, err, "failed to read vault file") + }) +} + +func TestRotateVault(t *testing.T) { + t.Run("RotateDecryptsWithNewKey", func(t *testing.T) { + oldKey := []byte("old-master-key-for-testing") + newKey := []byte("new-master-key-for-testing") + + encrypted, err := Encrypt([]byte("my-secret-value"), oldKey) + assert.NilError(t, err) + + vault := &Vault{ + Secrets: map[string]VaultSecret{ + "test_secret": { + Type: TypeGeneric, + Encrypted: encrypted, + Destination: "/etc/test", + }, + }, + } + + fileRefs, err := RotateVault(vault, oldKey, newKey) + assert.NilError(t, err) + assert.Equal(t, 0, len(fileRefs)) + + decrypted, err := Decrypt(vault.Secrets["test_secret"].Encrypted, newKey) + assert.NilError(t, err) + assert.Equal(t, "my-secret-value", string(decrypted)) + }) + + t.Run("OldKeyNoLongerWorks", func(t *testing.T) { + oldKey := []byte("old-master-key-for-testing") + newKey := []byte("new-master-key-for-testing") + + encrypted, err := Encrypt([]byte("my-secret-value"), oldKey) + assert.NilError(t, err) + + vault := &Vault{ + Secrets: map[string]VaultSecret{ + "test_secret": { + Type: TypeGeneric, + Encrypted: encrypted, + Destination: "/etc/test", + }, + }, + } + + _, err = RotateVault(vault, oldKey, newKey) + assert.NilError(t, err) + + _, err = Decrypt(vault.Secrets["test_secret"].Encrypted, oldKey) + assert.ErrorContains(t, err, "cipher: message authentication failed") + }) + + t.Run("FileRefsReported", func(t *testing.T) { + oldKey := []byte("old-master-key-for-testing") + newKey := []byte("new-master-key-for-testing") + + encrypted, err := Encrypt([]byte("file-secret"), oldKey) + assert.NilError(t, err) + + encFile := filepath.Join(t.TempDir(), "secret.enc") + err = os.WriteFile(encFile, []byte(encrypted), 0600) + assert.NilError(t, err) + + vault := &Vault{ + Secrets: map[string]VaultSecret{ + "file_secret": { + Type: TypeGeneric, + Encrypted: "file:" + encFile, + Destination: "/etc/test", + }, + }, + } + + fileRefs, err := RotateVault(vault, oldKey, newKey) + assert.NilError(t, err) + assert.Equal(t, 1, len(fileRefs)) + assert.Equal(t, "file_secret", fileRefs[0]) + + assert.Assert(t, !strings.HasPrefix(vault.Secrets["file_secret"].Encrypted, "file:")) + }) + + t.Run("MultipleSecrets", func(t *testing.T) { + oldKey := []byte("old-master-key-for-testing") + newKey := []byte("new-master-key-for-testing") + + enc1, err := Encrypt([]byte("secret-1"), oldKey) + assert.NilError(t, err) + enc2, err := Encrypt([]byte("secret-2"), oldKey) + assert.NilError(t, err) + + vault := &Vault{ + Secrets: map[string]VaultSecret{ + "first": {Encrypted: enc1, Destination: "/etc/first"}, + "second": {Encrypted: enc2, Destination: "/etc/second"}, + }, + } + + _, err = RotateVault(vault, oldKey, newKey) + assert.NilError(t, err) + + dec1, err := Decrypt(vault.Secrets["first"].Encrypted, newKey) + assert.NilError(t, err) + assert.Equal(t, "secret-1", string(dec1)) + + dec2, err := Decrypt(vault.Secrets["second"].Encrypted, newKey) + assert.NilError(t, err) + assert.Equal(t, "secret-2", string(dec2)) + }) +} + +func TestSaveVault(t *testing.T) { + t.Run("RoundTrip", func(t *testing.T) { + vault := &Vault{ + Secrets: map[string]VaultSecret{ + "db_password": { + Type: TypeGeneric, + Encrypted: "salt$cipher", + Destination: "/etc/db/password", + Mode: "0o600", + }, + }, + } + + vaultFile := filepath.Join(t.TempDir(), "vault.yaml") + err := SaveVault(vaultFile, vault) + assert.NilError(t, err) + + loaded, err := LoadRawVault(vaultFile) + assert.NilError(t, err) + assert.Equal(t, 1, len(loaded.Secrets)) + assert.Equal(t, "salt$cipher", loaded.Secrets["db_password"].Encrypted) + assert.Equal(t, "/etc/db/password", loaded.Secrets["db_password"].Destination) + assert.Equal(t, TypeGeneric, loaded.Secrets["db_password"].Type) + assert.Equal(t, "0o600", loaded.Secrets["db_password"].Mode) + }) + + t.Run("FilePermissions", func(t *testing.T) { + vault := &Vault{Secrets: map[string]VaultSecret{}} + vaultFile := filepath.Join(t.TempDir(), "vault.yaml") + + err := SaveVault(vaultFile, vault) + assert.NilError(t, err) + + info, err := os.Stat(vaultFile) + assert.NilError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + }) + + t.Run("EmptyVault", func(t *testing.T) { + vault := &Vault{Secrets: map[string]VaultSecret{}} + vaultFile := filepath.Join(t.TempDir(), "vault.yaml") + + err := SaveVault(vaultFile, vault) + assert.NilError(t, err) + + loaded, err := LoadRawVault(vaultFile) + assert.NilError(t, err) + assert.Equal(t, 0, len(loaded.Secrets)) + }) +} diff --git a/internals/styles/format.go b/internals/styles/format.go index 3e4a009..cc621f5 100644 --- a/internals/styles/format.go +++ b/internals/styles/format.go @@ -1,6 +1,10 @@ package styles -import "fmt" +import ( + "fmt" + "strings" + "time" +) func FormatBytes(bytes uint64) string { const unit = 1024 @@ -25,3 +29,40 @@ func FormatPercent(used, total uint64) string { return fmt.Sprintf("%.1f%%", float64(used)/float64(total)*100) } + +func FormatDuration(duration time.Duration) string { + days := int(duration.Hours() / 24) + hours := int(duration.Hours()) % 24 + minutes := int(duration.Minutes()) % 60 + + var parts []string + + if days > 0 { + parts = append(parts, fmt.Sprintf("%d days", days)) + } + + if hours > 0 { + parts = append(parts, fmt.Sprintf("%d hours", hours)) + } + + if minutes > 0 { + parts = append(parts, fmt.Sprintf("%d minutes", minutes)) + } + + if len(parts) == 0 { + return "just now" + } + + return strings.Join(parts, ", ") + " ago" +} + +func FormatCPUTime(seconds float64) string { + switch { + case seconds < 60: + return fmt.Sprintf("%.1fs", seconds) + case seconds < 3600: + return fmt.Sprintf("%.1fm", seconds/60) + default: + return fmt.Sprintf("%.1fh", seconds/3600) + } +} diff --git a/internals/styles/list.go b/internals/styles/list.go index af8c6c1..b09f349 100644 --- a/internals/styles/list.go +++ b/internals/styles/list.go @@ -27,3 +27,24 @@ func List(items ...any) *list.List { func NumberedList(items ...any) *list.List { return List(items...).Enumerator(list.Arabic) } + +type DescriptionItem struct { + Name string + Description string +} + +func DescriptionList(items []DescriptionItem) []any { + maxLen := 0 + for _, item := range items { + if len(item.Name) > maxLen { + maxLen = len(item.Name) + } + } + result := make([]any, len(items)) + for i, item := range items { + result[i] = Key().Width(maxLen).Render(item.Name) + + Muted().Render(" β€” ") + + Value().Render(item.Description) + } + return result +} diff --git a/internals/styles/output.go b/internals/styles/output.go index 092311d..354928c 100644 --- a/internals/styles/output.go +++ b/internals/styles/output.go @@ -3,14 +3,11 @@ package styles import ( "fmt" "io" - - "github.com/spf13/cobra" ) -func OutputRaw(cmd *cobra.Command, value string) bool { - raw, _ := cmd.Flags().GetBool("raw") +func OutputRaw(w io.Writer, raw bool, value string) bool { if raw { - fmt.Fprintln(cmd.OutOrStdout(), value) + fmt.Fprintln(w, value) return true } return false diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..f225ab9 --- /dev/null +++ b/plan.md @@ -0,0 +1,417 @@ +# Overview + +Add a `ws-cli files sync` command that syncs files/directories from a YAML manifest using native Go file operations. This consolidates and simplifies the two original plan attempts. + +## Scope + +**In scope:** ws-cli implementation only +**Out of scope:** Workspace repo changes (startup scripts, env.reference.yaml, integration tests) + +## Simplifications from Original Plan + +1. **No Ansible at all** - Use native Go file operations via existing `internals/io` module. This avoids Jinja2 templating security concerns entirely and keeps the implementation simple. + +2. **No templating** - Content is written literally. No Jinja2, no variable substitution in file contents. + +3. **Allow `~` in paths** - Expand `~` to home directory before validation. Paths are validated after expansion. + +4. **Reuse existing internals** - Use `internals/io.CopyFile()`, `io.WriteSecureFile()`, and `os.MkdirAll()` for all file operations. Add validation functions to existing `internals/path/support.go`. + +## Files to Create/Modify + +| File | Action | Description | +|------|--------|-------------| +| `internals/path/security.go` | Create | Denylist definitions and validation logic | +| `internals/path/security_test.go` | Create | Security validation tests | +| `internals/path/support.go` | Modify | Add `ValidateDestination()`, `ValidateSource()` wrappers | +| `internals/config/defaults.go` | Modify | Add `EnvStartupFilesSync` constant | +| `internals/files/manifest.go` | Create | YAML parsing and validation | +| `internals/files/sync.go` | Create | Sync orchestration using native Go | +| `cmd/files/files.go` | Create | Parent command | +| `cmd/files/sync.go` | Create | Sync subcommand | +| `cmd/secrets/vault.go` | Modify | Add `path.ValidateDestination()` call | +| `cmd/root.go` | Modify | Register `files.FilesCmd` | + +## Manifest Schema + +- Paths: absolute or `~` (expanded to home directory) +- Content: written literally (no templating, no variable expansion) + +```yaml +files: + - copy: + src: /workspace/configs/app.conf + dest: ~/.config/app/config.conf + mode: "0644" + + - copy: + src: /run/secrets/gitconfig + dest: + - ~/.gitconfig + - /workspace/.gitconfig + mode: "0600" + + - content: + data: | + DEBUG=false + LOG_LEVEL=info + dest: ~/.env + mode: "0600" + + - ensure: + path: ~/.local/logs + state: directory + mode: "0755" +``` + +## Data Structures + +```go +// internals/files/manifest.go + +type SyncManifest struct { + Files []SyncFile `yaml:"files"` +} + +type SyncFile struct { + Copy *CopyOp `yaml:"copy,omitempty"` + Content *ContentOp `yaml:"content,omitempty"` + Ensure *EnsureOp `yaml:"ensure,omitempty"` +} + +type CopyOp struct { + Src string `yaml:"src"` + Dest StringOrList `yaml:"dest"` + Mode string `yaml:"mode,omitempty"` +} + +type ContentOp struct { + Data string `yaml:"data,omitempty"` + Base64 string `yaml:"data_base64,omitempty"` + Dest string `yaml:"dest"` + Mode string `yaml:"mode,omitempty"` +} + +type EnsureOp struct { + Path string `yaml:"path"` + State string `yaml:"state"` // directory, file + Mode string `yaml:"mode,omitempty"` +} + +type StringOrList []string // Custom unmarshaler for single string or list +``` + +## Security Model + +### Threat Model + +Users could attempt to compromise the workspace by writing to sensitive locations: + +1. **Autoload script injection** - Write malicious scripts to autoload directories that execute during workspace startup +2. **SSH key injection** - Write to `~/.ssh/authorized_keys` to grant unauthorized access + +**Note:** System paths like `/etc/`, `/usr/`, `/var/` are already protected by Linux file permissions since the container runs as non-root user `kloud`. This was verified by integration test `test_vault_blocked_system_path` which confirms writes to `/etc/sudoers.d/` fail with "permission denied". + +### Defense Strategy: Denylist for User-Writable Paths + +Since system paths are protected by OS permissions, we only need application-level protection for user-writable sensitive paths within the allowed directories. + +## Path Validation (in existing support.go) + +**Location:** `internals/path/support.go` + +Add validation functions to existing module: + +```go +func ValidateDestination(p string) error { + // 1. Expand ~ to home directory + // 2. Clean with filepath.Clean() + // 3. Require absolute path + // 4. Check against allowed prefixes + // 5. Check against denied paths/patterns + // 6. Resolve symlinks and re-validate target +} + +func ValidateSource(p string) error { + // Same logic, different allowed/denied lists +} +``` + +### Allowed Prefixes (Allowlist) + +**Destinations:** + +- `$HOME` (user's home directory) +- `/workspace` +- `/tmp` + +Note: System paths (`/etc/`, `/usr/`, `/var/`, etc.) are inherently blocked because they're not in the allowlist AND Linux file permissions prevent writes from the non-root `kloud` user. + +**Sources (additional):** + +- `/run/secrets` + +### Denied Paths (Denylist) + +Only user-writable sensitive paths need application-level blocking. System paths (`/etc/`, `/usr/`, `/var/`, etc.) are already protected by Linux file permissions. + +**Workspace protected paths:** + +- `/workspace/.kloudkit/` - Workspace internal configuration +- `/workspace/.autoload/` - Startup scripts executed during boot +- `/workspace/.startup/` - Alternative startup script location +- `/workspace/.hooks/` - Lifecycle hooks +- `/workspace/.devcontainer/` - Dev container configuration + +**Home directory protected paths:** + +- `~/.ssh/authorized_keys` - SSH access control +- `~/.ssh/authorized_keys2` - SSH access control (alternate) +- `~/.ssh/rc` - SSH login script +- `~/.ssh/environment` - SSH environment +- `~/.gnupg/` - GPG keys and config +- `~/.kloudkit/` - CLI internal config + +**Pattern-based denials (substring match):** + +- Paths containing `autoload` (any case) +- Paths containing `.startup` + +### Symlink Protection + +Validate both the requested path AND the resolved path after symlink resolution: + +```go +func ValidateDestination(p string) error { + expanded, err := Expand(p) + if err != nil { + return err + } + + // Validate the literal path first + if err := validateAgainstRules(expanded, destAllowed, destDenied); err != nil { + return err + } + + // If path exists, resolve symlinks and re-validate + if resolved, err := filepath.EvalSymlinks(expanded); err == nil && resolved != expanded { + if err := validateAgainstRules(resolved, destAllowed, destDenied); err != nil { + return fmt.Errorf("symlink target blocked: %w", err) + } + } + + return nil +} +``` + +This prevents attacks where a user creates a symlink like `/workspace/innocent` β†’ `/etc/passwd`. + +### Validation Flow + +``` +1. Expand ~ to home directory using path.Expand() +2. Clean with filepath.Clean() to normalize .. +3. Require absolute path (starts with /) +4. Check against allowed prefixes (must match at least one) +5. Check against denied paths (must not match any) +6. Check against denied patterns (must not contain any) +7. If path exists, resolve symlinks and repeat steps 4-6 on target +``` + +### Error Messages + +Clear, actionable error messages help legitimate users: + +``` +path '/workspace/.autoload/script.sh' is protected: startup scripts cannot be modified +path '/etc/passwd' is outside allowed directories (allowed: $HOME, /workspace, /tmp) +path '/workspace/link' resolves to '/etc/shadow' which is protected +``` + +### Extensibility + +The denylist is defined as package-level variables, making it easy to extend: + +```go +// internals/path/security.go + +// System paths (/etc/, /usr/, /var/, etc.) are NOT included here +// because Linux file permissions already block writes from the +// non-root 'kloud' user. Only user-writable sensitive paths need +// application-level protection. + +var DeniedDestSuffixes = []string{ + "/.kloudkit/", + "/.autoload/", + "/.startup/", + "/.hooks/", + "/.devcontainer/", + "/.gnupg/", +} + +var DeniedDestExact = []string{ + "/.ssh/authorized_keys", + "/.ssh/authorized_keys2", + "/.ssh/rc", + "/.ssh/environment", +} + +var DeniedDestPatterns = []string{ + "autoload", + ".startup", +} +``` + +To add additional protection, append to the relevant slice. No code changes needed beyond updating the lists. + +### Future Considerations + +If users need legitimate access to protected paths (e.g., custom SSH config), consider: + +1. **Explicit opt-in flag**: `--allow-protected` with confirmation prompt +2. **Separate allowlist file**: Admin-managed list of exceptions +3. **Per-path override**: `force: true` in manifest with warning output + +These are not implemented in this plan but the architecture supports adding them later. + +## Vault Security Fix + +**Current state:** Linux file permissions already protect system paths (`/etc/`, `/usr/`, etc.) since the container runs as non-root user `kloud`. This is verified by integration test `test_vault_blocked_system_path`. + +**Remaining gap:** User-writable sensitive paths are not protected by OS permissions. + +**Attack vectors to close:** + +- Writing to `/workspace/.autoload/` to inject startup scripts +- Writing to `~/.ssh/authorized_keys` to grant SSH access +- Creating symlinks that point to protected paths + +**Fix:** Add `path.ValidateDestination()` call in vault processing before writing files. + +```go +// In secrets vault processing, before writing: +if err := path.ValidateDestination(secret.Path); err != nil { + return fmt.Errorf("secret '%s': %w", secret.Name, err) +} +``` + +The same denylist rules apply to vault as to file sync, providing consistent security across both features. + +## Native Go File Operations + +Instead of Ansible, use existing `internals/io` functions: + +| Operation | Go Implementation | +|-----------|-------------------| +| `copy` | `io.CopyFile()` + `os.Chmod()` | +| `content` | `io.WriteSecureFile()` | +| `ensure` (directory) | `os.MkdirAll()` + `os.Chmod()` | +| `ensure` (file) | `os.OpenFile()` + close (touch) | + +This keeps the implementation simple and avoids templating security concerns. + +## Command Interface + +``` +ws-cli files sync [--input=] + +Flags: + --input Path to YAML manifest (default: $WS_STARTUP_FILES_SYNC) +``` + +**Resolution order:** +1. `--input` flag if provided +2. `WS_STARTUP_FILES_SYNC` env var +3. Error if neither set + +## Execution Flow + +``` +1. Parse --input flag or read WS_STARTUP_FILES_SYNC +2. Validate manifest path exists +3. Parse YAML manifest +4. Validate each file entry: + - Exactly one operation type (copy/content/ensure) + - Expand ~ and validate all paths +5. Execute operations using native Go: + - copy: io.CopyFile() + os.Chmod() + - content: io.WriteSecureFile() + - ensure: os.MkdirAll() or touch +6. Print success/error summary +``` + +## Error Handling + +- **Missing manifest path:** Error with usage hint +- **Invalid YAML:** Error with parse details +- **Path validation failure:** Error with specific file index and path +- **File operation failure:** Print error, continue with remaining files, exit 1 at end if any failed + +## Implementation Order + +**Phase 1: Security foundation (closes existing vulnerability)** +1. `internals/path/security.go` - Denylist definitions and core validation +2. `internals/path/security_test.go` - Comprehensive security tests +3. `internals/path/support.go` - Add `ValidateDestination()`, `ValidateSource()` wrappers +4. `cmd/secrets/vault.go` - Add validation call (immediately closes security gap) + +**Phase 2: File sync feature** +5. `internals/config/defaults.go` - Add `EnvStartupFilesSync` constant +6. `internals/files/manifest.go` - Types + YAML parsing +7. `internals/files/sync.go` - Orchestration using native Go (reuses security validation) +8. `cmd/files/files.go` - Parent command +9. `cmd/files/sync.go` - Subcommand +10. `cmd/root.go` - Register `files.FilesCmd` + +## Verification + +**Path validation tests:** +```bash +go test ./internals/path/... -v +``` + +**Files sync - functional tests:** +1. Create test manifest at `/tmp/test-sync.yaml` +2. Run `ws-cli files sync --input=/tmp/test-sync.yaml` +3. Verify files created with correct permissions +4. Test `~` expansion works correctly + +**Security tests - protected paths:** + +Note: System paths (`/etc/`, `/usr/`, etc.) are protected by Linux file permissions. +This is verified by integration test `test_vault_blocked_system_path` in the workspace repo. + +| Test Case | Path | Expected | +|-----------|------|----------| +| Autoload injection | `/workspace/.autoload/evil.sh` | Rejected: startup scripts protected | +| Autoload pattern | `/workspace/foo/autoload/bar` | Rejected: contains 'autoload' | +| SSH keys | `~/.ssh/authorized_keys` | Rejected: SSH access control protected | +| SSH rc | `~/.ssh/rc` | Rejected: SSH login script protected | +| Hooks | `/workspace/.hooks/pre-start` | Rejected: lifecycle hooks protected | +| Devcontainer | `/workspace/.devcontainer/devcontainer.json` | Rejected: dev container config protected | + +**Security tests - symlink attacks:** + +| Test Case | Setup | Expected | +|-----------|-------|----------| +| Symlink to /etc | Create `/workspace/link` β†’ `/etc/passwd` | Rejected: symlink target blocked | +| Symlink to autoload | Create `/tmp/link` β†’ `/workspace/.autoload/` | Rejected: symlink target blocked | +| Nested symlink | `/workspace/a` β†’ `/workspace/b` β†’ `/etc/` | Rejected: final target blocked | + +**Security tests - allowed paths (should succeed):** + +| Test Case | Path | Expected | +|-----------|------|----------| +| Home config | `~/.config/app/settings.json` | Allowed | +| Workspace file | `/workspace/myproject/.env` | Allowed | +| Tmp file | `/tmp/cache.txt` | Allowed | +| Home env | `~/.env` | Allowed | + +**Vault security:** + +System paths are already protected by Linux permissions (verified by `test_vault_blocked_system_path`). + +1. Create vault with path to `/workspace/.autoload/` β†’ Rejected +2. Create vault with path to `~/.ssh/authorized_keys` β†’ Rejected +3. Create vault with path to `~/.env` β†’ Allowed +4. Create vault with path to `/workspace/.env` β†’ Allowed diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..22a9943 --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"] +} From a8a4284b1d8489c5754aea6dc01da5e5fd17f3cc Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Mon, 27 Apr 2026 08:25:11 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/root.go | 2 +- go.mod | 43 ++++++++------- go.sum | 104 ++++++++++++++++++++++--------------- internals/logger/logger.go | 2 +- internals/styles/list.go | 4 +- internals/styles/styles.go | 2 +- internals/styles/table.go | 4 +- internals/styles/theme.go | 4 +- 8 files changed, 93 insertions(+), 72 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 06865de..4a68d6f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,7 +4,7 @@ import ( "context" "os" - "github.com/charmbracelet/fang" + "charm.land/fang/v2" "github.com/kloudkit/ws-cli/cmd/clip" "github.com/kloudkit/ws-cli/cmd/feature" "github.com/kloudkit/ws-cli/cmd/info" diff --git a/go.mod b/go.mod index be4fe6a..5ac9a4e 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,17 @@ module github.com/kloudkit/ws-cli -go 1.25 +go 1.25.0 toolchain go1.25.5 require ( - github.com/charmbracelet/fang v0.4.1 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 + charm.land/fang/v2 v2.0.1 + charm.land/lipgloss/v2 v2.0.3 github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - golang.org/x/crypto v0.46.0 - golang.org/x/term v0.39.0 + golang.org/x/crypto v0.50.0 + golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.2 ) @@ -19,17 +19,20 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260422141423-a0f1f21775f7 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20260426004601-d5e63ff0b9ca // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.2.0 // indirect github.com/muesli/mango-cobra v1.3.0 // indirect @@ -37,13 +40,13 @@ require ( github.com/muesli/roff v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.32.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index b057c41..d97fad0 100644 --- a/go.sum +++ b/go.sum @@ -1,40 +1,53 @@ -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= +charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= -github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= -github.com/charmbracelet/fang v0.4.1 h1:NC0Y4oqg7YuZcBg/KKsHy8DSow0ZDjF4UJL7LwtA0dE= -github.com/charmbracelet/fang v0.4.1/go.mod h1:9gCUAHmVx5BwSafeyNr3GI0GgvlB1WYjL21SkPp1jyU= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383 h1:xGojlO6kHCDB1k6DolME79LG0u90TzVd8atGhmxFRIo= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260422141423-a0f1f21775f7 h1:PeRlqWGEoO0apcS62iEgxQhVnFCTOYyQvi2sUTdf6IE= +github.com/charmbracelet/ultraviolet v0.0.0-20260422141423-a0f1f21775f7/go.mod h1:3YdTxlnV/L0bQ3VN8WOSw8doF7LZV/xawUQ4MuAPDvo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260426004601-d5e63ff0b9ca h1:/tGUqs2h/DoQZztzFFPDABBOg/UAbfWoJ46JWUazNDs= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260426004601-d5e63ff0b9ca/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= @@ -53,12 +66,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -69,25 +84,28 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/internals/logger/logger.go b/internals/logger/logger.go index 7b3a829..40a9482 100644 --- a/internals/logger/logger.go +++ b/internals/logger/logger.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/lipgloss/v2" "github.com/kloudkit/ws-cli/internals/config" "github.com/kloudkit/ws-cli/internals/env" "github.com/kloudkit/ws-cli/internals/styles" diff --git a/internals/styles/list.go b/internals/styles/list.go index b09f349..68f64a6 100644 --- a/internals/styles/list.go +++ b/internals/styles/list.go @@ -1,8 +1,8 @@ package styles import ( - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/lipgloss/v2/list" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/list" ) func ListStyle() lipgloss.Style { diff --git a/internals/styles/styles.go b/internals/styles/styles.go index 20c9243..e8c1515 100644 --- a/internals/styles/styles.go +++ b/internals/styles/styles.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/lipgloss/v2" ) func Header() lipgloss.Style { diff --git a/internals/styles/table.go b/internals/styles/table.go index a29a9fc..c9027f5 100644 --- a/internals/styles/table.go +++ b/internals/styles/table.go @@ -1,8 +1,8 @@ package styles import ( - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/lipgloss/v2/table" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" ) func TableBorderStyle() lipgloss.Style { diff --git a/internals/styles/theme.go b/internals/styles/theme.go index 84c21b6..7844345 100644 --- a/internals/styles/theme.go +++ b/internals/styles/theme.go @@ -3,8 +3,8 @@ package styles import ( "image/color" - "github.com/charmbracelet/fang" - lipgloss "github.com/charmbracelet/lipgloss/v2" + "charm.land/fang/v2" + lipgloss "charm.land/lipgloss/v2" ) var (