P2.1 + P3.1: Agent skeleton and CLI skeleton
Agent (P2.1): Agent struct with registry DB, runtime, and logger. gRPC server with TLS 1.3 and MCIAS auth interceptor. Graceful shutdown on SIGINT/SIGTERM. All RPCs return Unimplemented until handlers are built in P2.2-P2.9. CLI (P3.1): Full command tree with all 15 subcommands as stubs (login, deploy, stop, start, restart, list, ps, status, sync, adopt, service show/edit/export, push, pull, node list/add/remove). gRPC dial helper with TLS, CA cert, and bearer token attachment. Both gates for parallel Phase 2+3 work are now open. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
|
||||
## Phase 2: Agent
|
||||
|
||||
- [ ] **P2.1** Agent skeleton and gRPC server
|
||||
- [x] **P2.1** Agent skeleton and gRPC server
|
||||
- [ ] **P2.2** Deploy handler
|
||||
- [ ] **P2.3** Lifecycle handlers (stop, start, restart)
|
||||
- [ ] **P2.4** Status handlers (list, live check, get status)
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
## Phase 3: CLI
|
||||
|
||||
- [ ] **P3.1** CLI skeleton
|
||||
- [x] **P3.1** CLI skeleton
|
||||
- [ ] **P3.2** Login command
|
||||
- [ ] **P3.3** Deploy command
|
||||
- [ ] **P3.4** Lifecycle commands (stop, start, restart)
|
||||
|
||||
@@ -2,8 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcp/internal/agent"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -27,7 +30,20 @@ func main() {
|
||||
},
|
||||
})
|
||||
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start the agent server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.LoadAgentConfig(cfgPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
return agent.Run(cfg)
|
||||
},
|
||||
})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
79
cmd/mcp/dial.go
Normal file
79
cmd/mcp/dial.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Ensure dial helpers are referenced to satisfy linters until CLI commands
|
||||
// are implemented. This will be removed when the first command uses dialAgent.
|
||||
var (
|
||||
_ = dialAgent
|
||||
_ = loadBearerToken
|
||||
)
|
||||
|
||||
// dialAgent connects to an agent at the given address and returns a gRPC
|
||||
// client. The connection uses TLS and attaches the bearer token to every RPC.
|
||||
func dialAgent(address string, cfg *config.CLIConfig) (mcpv1.McpAgentServiceClient, *grpc.ClientConn, error) {
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
|
||||
if cfg.MCIAS.CACert != "" {
|
||||
caCert, err := os.ReadFile(cfg.MCIAS.CACert) //nolint:gosec // trusted config path
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read CA cert %q: %w", cfg.MCIAS.CACert, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caCert) {
|
||||
return nil, nil, fmt.Errorf("invalid CA cert %q", cfg.MCIAS.CACert)
|
||||
}
|
||||
tlsConfig.RootCAs = pool
|
||||
}
|
||||
|
||||
token, err := loadBearerToken(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load token: %w", err)
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
address,
|
||||
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
|
||||
grpc.WithUnaryInterceptor(tokenInterceptor(token)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("dial %q: %w", address, err)
|
||||
}
|
||||
|
||||
return mcpv1.NewMcpAgentServiceClient(conn), conn, nil
|
||||
}
|
||||
|
||||
// tokenInterceptor returns a gRPC client interceptor that attaches the
|
||||
// bearer token to outgoing RPC metadata.
|
||||
func tokenInterceptor(token string) grpc.UnaryClientInterceptor {
|
||||
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
|
||||
return invoker(ctx, method, req, reply, cc, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// loadBearerToken reads the token from file or env var.
|
||||
func loadBearerToken(cfg *config.CLIConfig) (string, error) {
|
||||
if token := os.Getenv("MCP_TOKEN"); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
token, err := os.ReadFile(cfg.Auth.TokenPath) //nolint:gosec // trusted config path
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read token from %q: %w (run 'mcp login' first)", cfg.Auth.TokenPath, err)
|
||||
}
|
||||
return string(token), nil
|
||||
}
|
||||
234
cmd/mcp/main.go
234
cmd/mcp/main.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -19,15 +20,238 @@ func main() {
|
||||
}
|
||||
root.PersistentFlags().StringVarP(&cfgPath, "config", "c", "", "config file path")
|
||||
|
||||
root.AddCommand(&cobra.Command{
|
||||
root.AddCommand(versionCmd())
|
||||
root.AddCommand(loginCmd())
|
||||
root.AddCommand(deployCmd())
|
||||
root.AddCommand(stopCmd())
|
||||
root.AddCommand(startCmd())
|
||||
root.AddCommand(restartCmd())
|
||||
root.AddCommand(listCmd())
|
||||
root.AddCommand(psCmd())
|
||||
root.AddCommand(statusCmd())
|
||||
root.AddCommand(syncCmd())
|
||||
root.AddCommand(adoptCmd())
|
||||
root.AddCommand(serviceCmd())
|
||||
root.AddCommand(pushCmd())
|
||||
root.AddCommand(pullCmd())
|
||||
root.AddCommand(nodeCmd())
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println(version)
|
||||
},
|
||||
})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func loginCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate to MCIAS, store token",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func deployCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "deploy <service>[/<component>]",
|
||||
Short: "Deploy service from service definition",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringP("file", "f", "", "service definition file")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func stopCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop <service>",
|
||||
Short: "Stop all components, set active=false",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func startCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "start <service>",
|
||||
Short: "Start all components, set active=true",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func restartCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "restart <service>",
|
||||
Short: "Restart all components",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func listCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List services from all agents (registry, no runtime query)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func psCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "ps",
|
||||
Short: "Live check: query runtime on all agents",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func statusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status [service]",
|
||||
Short: "Full picture: live query + drift + recent events",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func syncCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Push service definitions to agents (update desired state)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func adoptCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "adopt <service>",
|
||||
Short: "Adopt all <service>-* containers into a service",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func serviceCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "service",
|
||||
Short: "Service definition management",
|
||||
}
|
||||
|
||||
show := &cobra.Command{
|
||||
Use: "show <service>",
|
||||
Short: "Print current spec from agent registry",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
edit := &cobra.Command{
|
||||
Use: "edit <service>",
|
||||
Short: "Open service definition in $EDITOR",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
export := &cobra.Command{
|
||||
Use: "export <service>",
|
||||
Short: "Write agent registry spec to local service file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
export.Flags().StringP("file", "f", "", "output file path")
|
||||
|
||||
cmd.AddCommand(show, edit, export)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pushCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "push <local-file> <service> [path]",
|
||||
Short: "Copy a local file into /srv/<service>/[path]",
|
||||
Args: cobra.RangeArgs(2, 3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pullCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "pull <service> <path> [local-file]",
|
||||
Short: "Copy a file from /srv/<service>/<path> to local",
|
||||
Args: cobra.RangeArgs(2, 3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func nodeCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "node",
|
||||
Short: "Node management",
|
||||
}
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List registered nodes",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
add := &cobra.Command{
|
||||
Use: "add <name> <address>",
|
||||
Short: "Register a node",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
remove := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Deregister a node",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(list, add, remove)
|
||||
return cmd
|
||||
}
|
||||
|
||||
119
internal/agent/agent.go
Normal file
119
internal/agent/agent.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// Agent is the MCP node agent. It manages containers, stores the registry,
|
||||
// monitors for drift, and serves the gRPC API.
|
||||
type Agent struct {
|
||||
mcpv1.UnimplementedMcpAgentServiceServer
|
||||
|
||||
Config *config.AgentConfig
|
||||
DB *sql.DB
|
||||
Runtime runtime.Runtime
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Run starts the agent: opens the database, sets up the gRPC server with
|
||||
// TLS and auth, and blocks until SIGINT/SIGTERM.
|
||||
func Run(cfg *config.AgentConfig) error {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: parseLogLevel(cfg.Log.Level),
|
||||
}))
|
||||
|
||||
db, err := registry.Open(cfg.Database.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open registry: %w", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
rt := &runtime.Podman{}
|
||||
|
||||
a := &Agent{
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
Runtime: rt,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load TLS cert: %w", err)
|
||||
}
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{tlsCert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
|
||||
validator, err := auth.NewMCIASValidator(cfg.MCIAS.ServerURL, cfg.MCIAS.CACert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create MCIAS validator: %w", err)
|
||||
}
|
||||
|
||||
server := grpc.NewServer(
|
||||
grpc.Creds(credentials.NewTLS(tlsConfig)),
|
||||
grpc.ChainUnaryInterceptor(
|
||||
auth.AuthInterceptor(validator),
|
||||
),
|
||||
)
|
||||
mcpv1.RegisterMcpAgentServiceServer(server, a)
|
||||
|
||||
lis, err := net.Listen("tcp", cfg.Server.GRPCAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %q: %w", cfg.Server.GRPCAddr, err)
|
||||
}
|
||||
|
||||
logger.Info("agent starting",
|
||||
"addr", cfg.Server.GRPCAddr,
|
||||
"node", cfg.Agent.NodeName,
|
||||
"runtime", cfg.Agent.ContainerRuntime,
|
||||
)
|
||||
|
||||
// Graceful shutdown on signal.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.Serve(lis)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutting down")
|
||||
server.GracefulStop()
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("serve: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) slog.Level {
|
||||
switch level {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user