8 Commits

Author SHA1 Message Date
3d2edb7c26 Fall back to podman logs when journalctl is inaccessible
Probe journalctl with -n 0 before committing to it. When the journal
is not readable (e.g. rootless podman without user journal storage),
fall back to podman logs instead of streaming the permission error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:54:14 -07:00
bf02935716 Add agent version to mcp node list
Thread the linker-injected version string into the Agent struct and
return it in the NodeStatus RPC. The CLI now dials each node and
displays the agent version alongside name and address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:49:49 -07:00
c4f0d7be8e Fix mcp logs permission error for rootless podman journald driver
Rootless podman writes container logs to the user journal, but
journalctl without --user only reads the system journal. Add --user
when the agent is running as a non-root user.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:46:01 -07:00
4d900eafd1 Derive flake version from git rev instead of hardcoding
Eliminates the manual version bump in flake.nix on each release.
Uses self.shortRev (or dirtyShortRev) since self.gitDescribe is not
yet available in this Nix version. Makefile builds still get the full
git describe output via ldflags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:48:44 -07:00
38f9070c24 Add top-level mcp edit command
Shortcut for mcp service edit — opens the service definition in $EDITOR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:44:19 -07:00
67d0ab1d9d Bump flake.nix version to 0.7.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:40:29 -07:00
7383b370f0 Fix mcp ps showing registry version instead of runtime, error on unknown component
mcp ps now uses the actual container image and version from the runtime
instead of the registry, which could be stale after a failed deploy.

Deploy now returns an error when the component filter matches nothing
instead of silently succeeding with zero results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:13:02 -07:00
4c847e6de9 Fix extraneous blank lines in mcp logs output
Skip empty lines from the scanner that result from double newlines
(application slog trailing newline + container runtime newline).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:22:38 -07:00
13 changed files with 114 additions and 27 deletions

View File

@@ -38,7 +38,7 @@ func main() {
if err != nil { if err != nil {
return fmt.Errorf("load config: %w", err) return fmt.Errorf("load config: %w", err)
} }
return agent.Run(cfg) return agent.Run(cfg, version)
}, },
}) })

12
cmd/mcp/edit.go Normal file
View File

@@ -0,0 +1,12 @@
package main
import "github.com/spf13/cobra"
func editCmd() *cobra.Command {
return &cobra.Command{
Use: "edit <service>",
Short: "Open service definition in $EDITOR",
Args: cobra.ExactArgs(1),
RunE: runServiceEdit,
}
}

View File

@@ -51,6 +51,7 @@ func main() {
root.AddCommand(nodeCmd()) root.AddCommand(nodeCmd())
root.AddCommand(purgeCmd()) root.AddCommand(purgeCmd())
root.AddCommand(logsCmd()) root.AddCommand(logsCmd())
root.AddCommand(editCmd())
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
log.Fatal(err) log.Fatal(err)

View File

@@ -1,12 +1,15 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"text/tabwriter" "text/tabwriter"
"time"
toml "github.com/pelletier/go-toml/v2" toml "github.com/pelletier/go-toml/v2"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config" "git.wntrmute.dev/mc/mcp/internal/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -48,13 +51,35 @@ func runNodeList(_ *cobra.Command, _ []string) error {
} }
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "NAME\tADDRESS") _, _ = fmt.Fprintln(w, "NAME\tADDRESS\tVERSION")
for _, n := range cfg.Nodes { for _, n := range cfg.Nodes {
_, _ = fmt.Fprintf(w, "%s\t%s\n", n.Name, n.Address) ver := queryAgentVersion(cfg, n.Address)
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", n.Name, n.Address, ver)
} }
return w.Flush() return w.Flush()
} }
// queryAgentVersion dials the agent and returns its version, or an error indicator.
func queryAgentVersion(cfg *config.CLIConfig, address string) string {
client, conn, err := dialAgent(address, cfg)
if err != nil {
return "error"
}
defer func() { _ = conn.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.NodeStatus(ctx, &mcpv1.NodeStatusRequest{})
if err != nil {
return "error"
}
if resp.AgentVersion == "" {
return "unknown"
}
return resp.AgentVersion
}
func runNodeAdd(_ *cobra.Command, args []string) error { func runNodeAdd(_ *cobra.Command, args []string) error {
cfg, err := config.LoadCLIConfig(cfgPath) cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil { if err != nil {

View File

@@ -10,7 +10,7 @@
let let
system = "x86_64-linux"; system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
version = "0.6.0"; version = pkgs.lib.removePrefix "v" (self.gitDescribe or self.shortRev or self.dirtyShortRev or "unknown");
in in
{ {
packages.${system} = { packages.${system} = {

View File

@@ -1926,6 +1926,7 @@ type NodeStatusResponse struct {
MemoryFreeBytes uint64 `protobuf:"varint,9,opt,name=memory_free_bytes,json=memoryFreeBytes,proto3" json:"memory_free_bytes,omitempty"` MemoryFreeBytes uint64 `protobuf:"varint,9,opt,name=memory_free_bytes,json=memoryFreeBytes,proto3" json:"memory_free_bytes,omitempty"`
CpuUsagePercent float64 `protobuf:"fixed64,10,opt,name=cpu_usage_percent,json=cpuUsagePercent,proto3" json:"cpu_usage_percent,omitempty"` CpuUsagePercent float64 `protobuf:"fixed64,10,opt,name=cpu_usage_percent,json=cpuUsagePercent,proto3" json:"cpu_usage_percent,omitempty"`
UptimeSince *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=uptime_since,json=uptimeSince,proto3" json:"uptime_since,omitempty"` UptimeSince *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=uptime_since,json=uptimeSince,proto3" json:"uptime_since,omitempty"`
AgentVersion string `protobuf:"bytes,12,opt,name=agent_version,json=agentVersion,proto3" json:"agent_version,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -2037,6 +2038,13 @@ func (x *NodeStatusResponse) GetUptimeSince() *timestamppb.Timestamp {
return nil return nil
} }
func (x *NodeStatusResponse) GetAgentVersion() string {
if x != nil {
return x.AgentVersion
}
return ""
}
type PurgeRequest struct { type PurgeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Service name (empty = all services). // Service name (empty = all services).
@@ -2474,7 +2482,7 @@ const file_proto_mcp_v1_mcp_proto_rawDesc = "" +
"\acontent\x18\x01 \x01(\fR\acontent\x12\x12\n" + "\acontent\x18\x01 \x01(\fR\acontent\x12\x12\n" +
"\x04mode\x18\x02 \x01(\rR\x04mode\x12\x14\n" + "\x04mode\x18\x02 \x01(\rR\x04mode\x12\x14\n" +
"\x05error\x18\x03 \x01(\tR\x05error\"\x13\n" + "\x05error\x18\x03 \x01(\tR\x05error\"\x13\n" +
"\x11NodeStatusRequest\"\xd9\x03\n" + "\x11NodeStatusRequest\"\xfe\x03\n" +
"\x12NodeStatusResponse\x12\x1b\n" + "\x12NodeStatusResponse\x12\x1b\n" +
"\tnode_name\x18\x01 \x01(\tR\bnodeName\x12\x18\n" + "\tnode_name\x18\x01 \x01(\tR\bnodeName\x12\x18\n" +
"\aruntime\x18\x02 \x01(\tR\aruntime\x12'\n" + "\aruntime\x18\x02 \x01(\tR\aruntime\x12'\n" +
@@ -2487,7 +2495,8 @@ const file_proto_mcp_v1_mcp_proto_rawDesc = "" +
"\x11memory_free_bytes\x18\t \x01(\x04R\x0fmemoryFreeBytes\x12*\n" + "\x11memory_free_bytes\x18\t \x01(\x04R\x0fmemoryFreeBytes\x12*\n" +
"\x11cpu_usage_percent\x18\n" + "\x11cpu_usage_percent\x18\n" +
" \x01(\x01R\x0fcpuUsagePercent\x12=\n" + " \x01(\x01R\x0fcpuUsagePercent\x12=\n" +
"\fuptime_since\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\vuptimeSince\"\x8e\x01\n" + "\fuptime_since\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\vuptimeSince\x12#\n" +
"\ragent_version\x18\f \x01(\tR\fagentVersion\"\x8e\x01\n" +
"\fPurgeRequest\x12\x18\n" + "\fPurgeRequest\x12\x18\n" +
"\aservice\x18\x01 \x01(\tR\aservice\x12\x1c\n" + "\aservice\x18\x01 \x01(\tR\aservice\x12\x1c\n" +
"\tcomponent\x18\x02 \x01(\tR\tcomponent\x12\x17\n" + "\tcomponent\x18\x02 \x01(\tR\tcomponent\x12\x17\n" +

View File

@@ -35,11 +35,12 @@ type Agent struct {
Proxy *ProxyRouter Proxy *ProxyRouter
Certs *CertProvisioner Certs *CertProvisioner
DNS *DNSRegistrar DNS *DNSRegistrar
Version string
} }
// Run starts the agent: opens the database, sets up the gRPC server with // Run starts the agent: opens the database, sets up the gRPC server with
// TLS and auth, and blocks until SIGINT/SIGTERM. // TLS and auth, and blocks until SIGINT/SIGTERM.
func Run(cfg *config.AgentConfig) error { func Run(cfg *config.AgentConfig, version string) error {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: parseLogLevel(cfg.Log.Level), Level: parseLogLevel(cfg.Log.Level),
})) }))
@@ -79,6 +80,7 @@ func Run(cfg *config.AgentConfig) error {
Proxy: proxy, Proxy: proxy,
Certs: certs, Certs: certs,
DNS: dns, DNS: dns,
Version: version,
} }
tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey) tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)

View File

@@ -34,6 +34,9 @@ func (a *Agent) Deploy(ctx context.Context, req *mcpv1.DeployRequest) (*mcpv1.De
filtered = append(filtered, cs) filtered = append(filtered, cs)
} }
} }
if len(filtered) == 0 {
return nil, fmt.Errorf("component %q not found in service %q", target, serviceName)
}
components = filtered components = filtered
} }

View File

@@ -63,8 +63,12 @@ func (a *Agent) Logs(req *mcpv1.LogsRequest, stream mcpv1.McpAgentService_LogsSe
scanner := bufio.NewScanner(pr) scanner := bufio.NewScanner(pr)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
if err := stream.Send(&mcpv1.LogsResponse{ if err := stream.Send(&mcpv1.LogsResponse{
Data: append(scanner.Bytes(), '\n'), Data: append(line, '\n'),
}); err != nil { }); err != nil {
_ = cmd.Process.Kill() _ = cmd.Process.Kill()
return err return err

View File

@@ -31,6 +31,7 @@ func (a *Agent) NodeStatus(ctx context.Context, _ *mcpv1.NodeStatusRequest) (*mc
Runtime: a.Config.Agent.ContainerRuntime, Runtime: a.Config.Agent.ContainerRuntime,
ServiceCount: uint32(len(services)), //nolint:gosec // bounded ServiceCount: uint32(len(services)), //nolint:gosec // bounded
ComponentCount: componentCount, ComponentCount: componentCount,
AgentVersion: a.Version,
} }
// Runtime version. // Runtime version.

View File

@@ -99,6 +99,12 @@ func (a *Agent) liveCheckServices(ctx context.Context) ([]*mcpv1.ServiceInfo, er
if rc, ok := runtimeByName[containerName]; ok { if rc, ok := runtimeByName[containerName]; ok {
ci.ObservedState = rc.State ci.ObservedState = rc.State
if rc.Version != "" {
ci.Version = rc.Version
}
if rc.Image != "" {
ci.Image = rc.Image
}
if !rc.Started.IsZero() { if !rc.Started.IsZero() {
ci.Started = timestamppb.New(rc.Started) ci.Started = timestamppb.New(rc.Started)
} }

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
@@ -179,16 +180,53 @@ func (p *Podman) Inspect(ctx context.Context, name string) (ContainerInfo, error
} }
// Logs returns an exec.Cmd that streams container logs. For containers // Logs returns an exec.Cmd that streams container logs. For containers
// using the journald log driver, it uses journalctl (podman logs can't // using the journald log driver, it tries journalctl first (podman logs
// read journald outside the originating user session). For k8s-file or // can't read journald outside the originating user session). If journalctl
// other drivers, it uses podman logs directly. // can't access the journal, it falls back to podman logs.
func (p *Podman) Logs(ctx context.Context, containerName string, tail int, follow, timestamps bool, since string) *exec.Cmd { func (p *Podman) Logs(ctx context.Context, containerName string, tail int, follow, timestamps bool, since string) *exec.Cmd {
// Check if this container uses the journald log driver. // Check if this container uses the journald log driver.
inspectCmd := exec.CommandContext(ctx, p.command(), "inspect", "--format", "{{.HostConfig.LogConfig.Type}}", containerName) //nolint:gosec inspectCmd := exec.CommandContext(ctx, p.command(), "inspect", "--format", "{{.HostConfig.LogConfig.Type}}", containerName) //nolint:gosec
if out, err := inspectCmd.Output(); err == nil && strings.TrimSpace(string(out)) == "journald" { if out, err := inspectCmd.Output(); err == nil && strings.TrimSpace(string(out)) == "journald" {
if p.journalAccessible(ctx, containerName) {
return p.journalLogs(ctx, containerName, tail, follow, since) return p.journalLogs(ctx, containerName, tail, follow, since)
} }
}
return p.podmanLogs(ctx, containerName, tail, follow, timestamps, since)
}
// journalAccessible probes whether journalctl can read logs for the container.
func (p *Podman) journalAccessible(ctx context.Context, containerName string) bool {
args := []string{"--no-pager", "-n", "0"}
if os.Getuid() != 0 {
args = append(args, "--user")
}
args = append(args, "CONTAINER_NAME="+containerName)
cmd := exec.CommandContext(ctx, "journalctl", args...) //nolint:gosec
return cmd.Run() == nil
}
// journalLogs returns a journalctl command filtered by container name.
func (p *Podman) journalLogs(ctx context.Context, containerName string, tail int, follow bool, since string) *exec.Cmd {
args := []string{"--no-pager", "--output", "cat"}
if os.Getuid() != 0 {
args = append(args, "--user")
}
args = append(args, "CONTAINER_NAME="+containerName)
if tail > 0 {
args = append(args, "--lines", fmt.Sprintf("%d", tail))
}
if follow {
args = append(args, "--follow")
}
if since != "" {
args = append(args, "--since", since)
}
return exec.CommandContext(ctx, "journalctl", args...) //nolint:gosec // args built programmatically
}
// podmanLogs returns a podman logs command.
func (p *Podman) podmanLogs(ctx context.Context, containerName string, tail int, follow, timestamps bool, since string) *exec.Cmd {
args := []string{"logs"} args := []string{"logs"}
if tail > 0 { if tail > 0 {
args = append(args, "--tail", fmt.Sprintf("%d", tail)) args = append(args, "--tail", fmt.Sprintf("%d", tail))
@@ -206,21 +244,6 @@ func (p *Podman) Logs(ctx context.Context, containerName string, tail int, follo
return exec.CommandContext(ctx, p.command(), args...) //nolint:gosec // args built programmatically return exec.CommandContext(ctx, p.command(), args...) //nolint:gosec // args built programmatically
} }
// journalLogs returns a journalctl command filtered by container name.
func (p *Podman) journalLogs(ctx context.Context, containerName string, tail int, follow bool, since string) *exec.Cmd {
args := []string{"--no-pager", "--output", "cat", "CONTAINER_NAME=" + containerName}
if tail > 0 {
args = append(args, "--lines", fmt.Sprintf("%d", tail))
}
if follow {
args = append(args, "--follow")
}
if since != "" {
args = append(args, "--since", since)
}
return exec.CommandContext(ctx, "journalctl", args...) //nolint:gosec // args built programmatically
}
// Login authenticates to a container registry using the given token as // Login authenticates to a container registry using the given token as
// the password. This enables non-interactive push with service account // the password. This enables non-interactive push with service account
// tokens (MCR accepts MCIAS JWTs as passwords). // tokens (MCR accepts MCIAS JWTs as passwords).

View File

@@ -257,6 +257,7 @@ message NodeStatusResponse {
uint64 memory_free_bytes = 9; uint64 memory_free_bytes = 9;
double cpu_usage_percent = 10; double cpu_usage_percent = 10;
google.protobuf.Timestamp uptime_since = 11; google.protobuf.Timestamp uptime_since = 11;
string agent_version = 12;
} }
// --- Purge --- // --- Purge ---