Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/mcp-operate-verbs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@kurrent/gaffer": patch
---

The MCP server gains the operate verbs, so an assistant can manage a deployed projection's lifecycle with a human in the loop:

- **`deploy_pause`** / **`deploy_resume`** / **`deploy_abort`** mirror `gaffer disable` / `enable` / `disable --abort`.
- **`deploy_recreate`** rebuilds from local config like `gaffer recreate`, gated on the compile and diagnostics preflight, and stamps `operation: recreate` in the deploy ledger.
- **`deploy_rollback`** redeploys a prior version by content hash from `deploy_history`, like `gaffer rollback`, and stamps `operation: rollback`.
- **`deploy_delete`** mirrors `gaffer delete`, including `deleteEmitted`.

Writes against a server that reports itself as production require a confirmation answered through the MCP client (elicitation); the assistant cannot answer it. Recreate and delete destroy state with no undo, so they ask every time, production or not. A client without elicitation support cannot perform gated writes; the refusal names the CLI command to run instead.
2 changes: 1 addition & 1 deletion cli/cmd/history_rollback_modal.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ func (m historyModel) rollbackModalBody(rb historyRollback, width int) []string
// The refusal names the dimension and the recreate escape; wrap it
// rather than truncating the actionable tail off.
var rows []string
msg := rollbackRefusal(rb.cmp, rb.sel.ContentHash, m.name).Error()
msg := remote.RollbackRefusal(rb.cmp, rb.sel.ContentHash, m.name).Error()
for line := range strings.SplitSeq(lipgloss.NewStyle().Width(width).Render(msg), "\n") {
rows = append(rows, m.tw.styles.warning.Render(line))
}
Expand Down
70 changes: 6 additions & 64 deletions cli/cmd/ledger.go
Original file line number Diff line number Diff line change
@@ -1,76 +1,18 @@
package cmd

import (
"os"
"os/exec"
"strings"

"github.com/kurrent-io/gaffer/cli/internal/config"
"github.com/kurrent-io/gaffer/cli/internal/engine"
"github.com/kurrent-io/gaffer/cli/internal/remote"
"github.com/kurrent-io/gaffer/cli/internal/stamp"
)

// toolLedger builds the tool metadata stamped on a create/update gaffer makes:
// always tool/version and the given operation, plus best-effort revision (source
// provenance) and actor (the identity gaffer connects as for connection/env).
// Empty best-effort fields are dropped on the wire by Ledger.metadata.
// toolLedger builds the tool metadata stamped on a create/update gaffer makes,
// resolving the live env from the command's flags first. An unresolvable env
// still stamps tool/version/operation/revision - only the actor is dropped.
func toolLedger(connection, env, operation string, cfg *config.Config, root string) remote.Ledger {
return remote.Ledger{
Tool: remote.ToolName,
ToolVersion: Version,
Operation: operation,
Revision: resolveRevision(root),
Actor: resolveActor(connection, env, cfg, root),
}
}

// resolveRevision returns the source revision to record: the project's git HEAD,
// or "" when it isn't a git checkout. GAFFER_REVISION overrides it - a CI-only knob
// for recording the canonical commit when the checkout's HEAD isn't it (e.g. a PR
// build's synthetic merge commit).
func resolveRevision(root string) string {
if r := os.Getenv("GAFFER_REVISION"); r != "" {
return r
}
return gitRevision(root)
}

// gitRevision is the git HEAD commit of dir, suffixed +changes when the working
// tree has uncommitted changes (tracked or untracked - an untracked projection
// file means deploying source that isn't in the commit). Best-effort: "" if dir
// isn't a git repo, git isn't installed, or HEAD is unborn.
func gitRevision(dir string) string {
head, err := git(dir, "rev-parse", "HEAD")
if err != nil || head == "" {
return ""
}
if dirty, err := git(dir, "status", "--porcelain"); err == nil && dirty != "" {
return head + "+changes"
}
return head
}

func git(dir string, args ...string) (string, error) {
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output() //nolint:gosec // git with controlled args
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}

// resolveActor returns the identity to record: the principal gaffer connects as
// for the target env (basic-auth username or OAuth client_id), or "" (omitted) when
// there's no user - an anonymous / cert-only connection, or the env can't be
// resolved. GAFFER_ACTOR overrides it - a CI-only knob for the pipeline/human
// identity, since the connection there is usually a service account. Client-asserted
// attribution, not a verified claim.
func resolveActor(connection, env string, cfg *config.Config, root string) string {
if a := os.Getenv("GAFFER_ACTOR"); a != "" {
return a
}
resolved, err := resolveLiveEnv(connection, env, cfg)
if err != nil {
return ""
resolved = config.ResolvedEnv{}
}
return engine.Principal(resolved.Connection, root, resolved.Name, resolved.OAuth)
return stamp.Ledger(resolved, operation, Version, root)
}
95 changes: 15 additions & 80 deletions cli/cmd/ledger_test.go
Original file line number Diff line number Diff line change
@@ -1,88 +1,23 @@
package cmd

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

// initGitRepo makes a temp git repo with one empty commit, isolated from the
// machine's global/system git config. Skips if git isn't on PATH.
func initGitRepo(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not on PATH")
}
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
c.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("init", "-q")
run("-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "--allow-empty", "-q", "-m", "init")
return dir
}

func TestGitRevisionClean(t *testing.T) {
dir := initGitRepo(t)
rev := gitRevision(dir)
if len(rev) != 40 {
t.Fatalf("revision = %q, want a 40-char HEAD SHA", rev)
}
if strings.Contains(rev, "+changes") {
t.Errorf("clean tree must not be marked dirty: %q", rev)
}
want, _ := git(dir, "rev-parse", "HEAD")
if rev != want {
t.Errorf("revision = %q, want HEAD %q", rev, want)
}
}

func TestGitRevisionDirty(t *testing.T) {
dir := initGitRepo(t)
if err := os.WriteFile(filepath.Join(dir, "untracked.js"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
rev := gitRevision(dir)
if !strings.HasSuffix(rev, "+changes") {
t.Errorf("revision = %q, want +changes suffix for an untracked file", rev)
}
}

func TestGitRevisionNotARepo(t *testing.T) {
if rev := gitRevision(t.TempDir()); rev != "" {
t.Errorf("revision = %q, want empty outside a git repo", rev)
}
}

func TestResolveRevision(t *testing.T) {
dir := initGitRepo(t)

t.Run("env overrides git", func(t *testing.T) {
t.Setenv("GAFFER_REVISION", "from-env")
if got := resolveRevision(dir); got != "from-env" {
t.Errorf("got %q, want from-env", got)
}
})
t.Run("git when no override", func(t *testing.T) {
t.Setenv("GAFFER_REVISION", "")
if got := resolveRevision(dir); len(got) != 40 {
t.Errorf("got %q, want the git HEAD", got)
}
})
}
"github.com/kurrent-io/gaffer/cli/internal/config"
"github.com/kurrent-io/gaffer/cli/internal/remote"
)

func TestResolveActorEnvOverride(t *testing.T) {
// GAFFER_ACTOR overrides the connection-derived principal. (The derived path is
// covered by engine.Principal's own tests.)
t.Setenv("GAFFER_ACTOR", "ci-bot")
if got := resolveActor("", "", nil, ""); got != "ci-bot" {
t.Errorf("got %q, want ci-bot", got)
// An unresolvable env (no --connection, no --env, no default) still stamps
// tool/version/operation - only the actor is dropped. Locks the wrapper's
// resolve-error-to-zero-env mapping, the seam mcpserver bypasses.
func TestToolLedgerUnresolvableEnv(t *testing.T) {
t.Setenv("GAFFER_ACTOR", "")
t.Setenv("GAFFER_REVISION", "")
led := toolLedger("", "", remote.OpDeploy, &config.Config{}, t.TempDir())
if led.Tool != remote.ToolName || led.Operation != remote.OpDeploy || led.ToolVersion != Version {
t.Errorf("ledger = %+v, want tool/operation/version stamped", led)
}
if led.Actor != "" {
t.Errorf("actor = %q, want empty for an unresolvable env", led.Actor)
}
}
138 changes: 9 additions & 129 deletions cli/cmd/rollback.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import (
"errors"
"fmt"
"io"
"slices"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -49,7 +47,7 @@ func runRollback(cmd *cobra.Command, name, hashArg string, opts operateOpts) err
if err := checkOperable(name); err != nil {
return err
}
prefix, err := normalizeHashPrefix(hashArg)
prefix, err := remote.NormalizeHashPrefix(hashArg)
if err != nil {
return err
}
Expand All @@ -76,36 +74,36 @@ func runRollback(cmd *cobra.Command, name, hashArg string, opts operateOpts) err
return err
}

tgt, err := findByHash(ctx, r, name, prefix)
tgt, err := r.FindVersionByHash(ctx, name, prefix)
if err != nil {
return err
}

currentDesc := current.Descriptor()
if tgt.hash == currentDesc.Hash() {
return renderRollback(cmd.OutOrStdout(), opts.JSON, name, "unchanged", tgt.hash, target)
if tgt.Hash == currentDesc.Hash() {
return renderRollback(cmd.OutOrStdout(), opts.JSON, name, "unchanged", tgt.Hash, target)
}

tgtDesc := tgt.def.Descriptor()
tgtDesc := tgt.Def.Descriptor()
cmp := deploy.Compare(tgtDesc, currentDesc)
if err := rollbackRefusal(cmp, tgt.hash, name); err != nil {
if err := remote.RollbackRefusal(cmp, tgt.Hash, name); err != nil {
return err
}

if !opts.JSON {
writeRollbackPreview(cmd.OutOrStdout(), name, currentDesc, tgtDesc, tgt.hash, cmp)
writeRollbackPreview(cmd.OutOrStdout(), name, currentDesc, tgtDesc, tgt.Hash, cmp)
}
if err := confirmOp(opQuestion("Roll back", name, target, prod), opts.Yes, opts.JSON); err != nil {
return err
}

ledger := toolLedger(opts.Connection, opts.Env, remote.OpRollback, cfg, root)
if err := rpc(ctx, func(ctx context.Context) error {
return r.Update(ctx, name, tgt.def.Query, remote.UpdateOptions{Emit: &tgt.def.Emit, Ledger: &ledger})
return r.Update(ctx, name, tgt.Def.Query, remote.UpdateOptions{Emit: &tgt.Def.Emit, Ledger: &ledger})
}); err != nil {
return fmt.Errorf("could not roll back %s: %w", name, err)
}
return renderRollback(cmd.OutOrStdout(), opts.JSON, name, "rolled-back", tgt.hash, target)
return renderRollback(cmd.OutOrStdout(), opts.JSON, name, "rolled-back", tgt.Hash, target)
}

// writeRollbackPreview shows what the rollback would change before the confirm:
Expand All @@ -128,124 +126,6 @@ func writeRollbackPreview(out io.Writer, name string, current, target deploy.Des
tw.blank()
}

// rollbackRefusal refuses a target that differs from the deployed projection in
// a create-only dimension: rollback redeploys in place via Update, which carries
// only the query and emit, so an engine version or emitted-stream tracking
// change can't be applied. Nil when the target is applyable.
func rollbackRefusal(cmp deploy.Comparison, hash, name string) error {
if !cmp.EngineVersionDiffers && !cmp.TrackEmittedStreamsDiffers {
return nil
}
dim := "engine version"
if !cmp.EngineVersionDiffers {
dim = "emitted-stream tracking"
}
return fmt.Errorf("version %s differs from the deployed projection in %s, which rollback can't change in place; update local config and use `gaffer recreate %s`",
shortHash(hash), dim, name)
}

// rollbackTarget is what a hash prefix resolved to in the projection's history:
// the definition to redeploy and its full content hash.
type rollbackTarget struct {
def *remote.Definition
hash string
}

// findByHash scans the projection's whole history for content whose hash matches
// the prefix. The same content at several versions is one match - the hash is the
// identity - so only a prefix straddling two different contents is ambiguous.
// Pages newest-first like the interactive timeline, bounded per read by the
// history hard cap and overall by the stream itself.
func findByHash(ctx context.Context, r *remote.Client, name, prefix string) (*rollbackTarget, error) {
matches := map[string]*remote.Definition{}
before := int64(-1)
for {
var versions []remote.Version
if err := rpc(ctx, func(ctx context.Context) error {
var err error
versions, _, err = r.ReadHistory(ctx, name, before, 0)
return err
}); err != nil {
return nil, err
}
if len(versions) == 0 {
break
}
matchHashes(versions, prefix, matches)
if scanSettled(prefix, matches) {
break
}
oldest := versions[len(versions)-1].Number
if oldest <= 0 {
break
}
before = oldest
}
return resolveHashMatches(matches, prefix, name)
}

// scanSettled reports whether older pages could still change the outcome: a
// full hash is exact, so one match settles it, and a second distinct content
// already proves the prefix ambiguous - either way the scan can stop early
// rather than paging a large history to its start.
func scanSettled(prefix string, matches map[string]*remote.Definition) bool {
return (len(prefix) == 64 && len(matches) > 0) || len(matches) > 1
}

// matchHashes collects the distinct contents in a page whose hash carries the
// prefix. Tombstones have no content of their own and are skipped.
func matchHashes(versions []remote.Version, prefix string, matches map[string]*remote.Definition) {
for _, v := range versions {
if v.Deleted || v.Definition == nil {
continue
}
h := v.Definition.Descriptor().Hash()
if strings.HasPrefix(h, prefix) {
if _, ok := matches[h]; !ok {
matches[h] = v.Definition
}
}
}
}

// resolveHashMatches turns the collected matches into the single target, or the
// not-found / ambiguous-prefix error.
func resolveHashMatches(matches map[string]*remote.Definition, prefix, name string) (*rollbackTarget, error) {
switch len(matches) {
case 0:
return nil, fmt.Errorf("no version matching %q in the history of %s", prefix, name)
case 1:
for h, d := range matches {
return &rollbackTarget{def: d, hash: h}, nil
}
}
hashes := make([]string, 0, len(matches))
for h := range matches {
hashes = append(hashes, shortHash(h))
}
slices.Sort(hashes)
return nil, fmt.Errorf("%q matches %d different versions (%s); give more characters", prefix, len(matches), strings.Join(hashes, ", "))
}

// normalizeHashPrefix validates the hash argument: lowercase hex, at least 4
// characters so a stray character can't match half the history, at most a full
// 64-character hash.
func normalizeHashPrefix(s string) (string, error) {
p := strings.ToLower(s)
if len(p) < 4 {
return "", fmt.Errorf("hash prefix %q is too short; give at least 4 characters", s)
}
if len(p) > 64 {
return "", fmt.Errorf("hash prefix %q is longer than a full content hash", s)
}
for _, c := range p {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return "", fmt.Errorf("hash prefix %q is not hexadecimal", s)
}
}
return p, nil
}

// rollbackJSON is the --json shape: the projection, the outcome (rolled-back, or
// unchanged when the target is already deployed), and the full target hash.
type rollbackJSON struct {
Expand Down
Loading
Loading