Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.9.3
github.com/google/uuid v1.6.0
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
Expand Down
11 changes: 8 additions & 3 deletions internal/cmd/target/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ func newMigrationCreateCmd() *cobra.Command {
Description: description,
ExporterMigrationGUID: exporterGUID,
}
req.Initiator = elmapi.TargetMigrationInitiatorCustomer
req.OperationID = elmapi.NewTargetMigrationOperationID()
raw, err := client.CreateTargetMigration(cmd.Context(), req)
if err != nil {
return annotateAuthError(err, targetURLResolved)
Expand Down Expand Up @@ -240,7 +242,8 @@ func newMigrationPauseCmd() *cobra.Command {
if err != nil {
return err
}
if err := client.PauseTargetMigration(cmd.Context(), migrationID); err != nil {
req := elmapi.NewTargetMigrationTransitionRequest()
if err := client.PauseTargetMigration(cmd.Context(), migrationID, req); err != nil {
return annotateMigrationActionError(err, targetURLResolved, migrationID)
}
fmt.Fprintf(cmd.OutOrStdout(), "Migration %d paused.\n", migrationID)
Expand Down Expand Up @@ -280,7 +283,8 @@ func newMigrationResumeCmd() *cobra.Command {
if err != nil {
return err
}
if err := client.ResumeTargetMigration(cmd.Context(), migrationID); err != nil {
req := elmapi.NewTargetMigrationTransitionRequest()
if err := client.ResumeTargetMigration(cmd.Context(), migrationID, req); err != nil {
return annotateMigrationActionError(err, targetURLResolved, migrationID)
}
fmt.Fprintf(cmd.OutOrStdout(), "Migration %d resumed.\n", migrationID)
Expand Down Expand Up @@ -323,7 +327,8 @@ func newMigrationAbortCmd() *cobra.Command {
if err != nil {
return err
}
if err := client.AbortTargetMigration(cmd.Context(), migrationID); err != nil {
req := elmapi.NewTargetMigrationTransitionRequest()
if err := client.AbortTargetMigration(cmd.Context(), migrationID, req); err != nil {
return annotateMigrationActionError(err, targetURLResolved, migrationID)
}
fmt.Fprintf(cmd.OutOrStdout(), "Migration %d aborted.\n", migrationID)
Expand Down
46 changes: 46 additions & 0 deletions internal/cmd/target/migration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import (
"strings"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/github/gh-elm/internal/elmapi"
)

func TestMigrationList(t *testing.T) {
Expand Down Expand Up @@ -131,6 +134,7 @@ func TestMigrationCreate(t *testing.T) {
assert.Equal(t, "https://source.example/octo/repo", gotBody["source_url"])
assert.Equal(t, []any{"octo/repo"}, gotBody["repositories"])
assert.Equal(t, "test migration", gotBody["description"])
assertCustomerTransition(t, gotBody)
assert.Contains(t, out, "Migration 42 created.")
assert.Contains(t, out, "Expires at:")
})
Expand Down Expand Up @@ -250,8 +254,10 @@ func TestMigrationStatus(t *testing.T) {
func TestMigrationPauseResumeAbort(t *testing.T) {
t.Run("pause posts to the pause endpoint and prints confirmation", func(t *testing.T) {
var gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
Expand All @@ -260,13 +266,17 @@ func TestMigrationPauseResumeAbort(t *testing.T) {
"--target-url", srv.URL, "--target-token", "tok")

assert.Equal(t, "/enterprise/migration/42/pause", gotPath)
assert.Len(t, gotBody, 2)
assertCustomerTransition(t, gotBody)
assert.Contains(t, out, "Migration 42 paused.")
})

t.Run("resume posts to the resume endpoint and prints confirmation", func(t *testing.T) {
var gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
Expand All @@ -275,13 +285,17 @@ func TestMigrationPauseResumeAbort(t *testing.T) {
"--target-url", srv.URL, "--target-token", "tok")

assert.Equal(t, "/enterprise/migration/42/resume", gotPath)
assert.Len(t, gotBody, 2)
assertCustomerTransition(t, gotBody)
assert.Contains(t, out, "Migration 42 resumed.")
})

t.Run("abort posts to the abort endpoint and prints confirmation", func(t *testing.T) {
var gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
Expand All @@ -290,9 +304,30 @@ func TestMigrationPauseResumeAbort(t *testing.T) {
"--target-url", srv.URL, "--target-token", "tok")

assert.Equal(t, "/enterprise/migration/42/abort", gotPath)
assert.Len(t, gotBody, 2)
assertCustomerTransition(t, gotBody)
assert.Contains(t, out, "Migration 42 aborted.")
})

t.Run("each command invocation generates a fresh operation ID", func(t *testing.T) {
var operationIDs []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
operationIDs = append(operationIDs, assertCustomerTransition(t, body))
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

runMigration(t, "migration", "pause", "--migration-id", "42",
"--target-url", srv.URL, "--target-token", "tok")
runMigration(t, "migration", "pause", "--migration-id", "42",
"--target-url", srv.URL, "--target-token", "tok")

require.Len(t, operationIDs, 2)
assert.NotEqual(t, operationIDs[0], operationIDs[1])
})

t.Run("requires --migration-id", func(t *testing.T) {
for _, sub := range []string{"pause", "resume", "abort"} {
err := runMigrationErr(t, "migration", sub, "--target-url", "https://x", "--target-token", "tok")
Expand Down Expand Up @@ -377,3 +412,14 @@ func execMigration(t *testing.T, args ...string) (string, error) {
err := cmd.Execute()
return buf.String(), err
}

func assertCustomerTransition(t *testing.T, body map[string]any) string {
t.Helper()

assert.Equal(t, elmapi.TargetMigrationInitiatorCustomer, body["initiator"])
assert.NotContains(t, body, "actor")
operationID, ok := body["operation_id"].(string)
require.True(t, ok, "operation_id must be a string")
require.NoError(t, uuid.Validate(operationID))
return operationID
}
43 changes: 37 additions & 6 deletions internal/elmapi/target_migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"net/url"
"strconv"
"time"

"github.com/google/uuid"
)

// targetMigrationBasePath is the base path for the target (GHEC/Proxima)
Expand All @@ -35,6 +37,33 @@ const (
// IterTargetMigrations.
const targetMigrationsPageSize = 100

// TargetMigrationInitiatorCustomer identifies lifecycle mutations initiated by
// a customer-operated gh-elm invocation.
const TargetMigrationInitiatorCustomer = "customer"

// TargetMigrationTransitionRequest attributes a target migration lifecycle
// mutation to the customer invocation that caused it.
type TargetMigrationTransitionRequest struct {
Initiator string `json:"initiator"`
OperationID string `json:"operation_id"`
}

// NewTargetMigrationOperationID returns the operation ID for one logical
// target migration lifecycle mutation. Callers should reuse it if they retry
// that mutation.
func NewTargetMigrationOperationID() string {
return uuid.NewString()
}

// NewTargetMigrationTransitionRequest returns attribution metadata for one
// customer-initiated target migration lifecycle mutation.
func NewTargetMigrationTransitionRequest() TargetMigrationTransitionRequest {
return TargetMigrationTransitionRequest{
Initiator: TargetMigrationInitiatorCustomer,
OperationID: NewTargetMigrationOperationID(),
}
}

// CreateTargetMigrationRequest is the body of a create-migration call against
// the target (GHEC/Proxima) migration-management API. Repositories currently
// accepts exactly one entry; the API does not yet support multi-repository
Expand All @@ -44,6 +73,8 @@ type CreateTargetMigrationRequest struct {
Repositories []string `json:"repositories"`
Description string `json:"description,omitempty"`
ExporterMigrationGUID string `json:"exporter_migration_guid,omitempty"`
Initiator string `json:"initiator"`
OperationID string `json:"operation_id"`
}

// CreateTargetMigration creates a migration on the target (GHEC/Proxima) side
Expand Down Expand Up @@ -204,29 +235,29 @@ func (c *Client) GetTargetMigrationStatus(ctx context.Context, migrationID int64

// PauseTargetMigration pauses a migration on the target (GHEC/Proxima) side.
// POST /enterprise/migration/{id}/pause. Returns 204.
func (c *Client) PauseTargetMigration(ctx context.Context, migrationID int64) error {
func (c *Client) PauseTargetMigration(ctx context.Context, migrationID int64, req TargetMigrationTransitionRequest) error {
path := fmt.Sprintf("%s/%d/pause", targetMigrationBasePath, migrationID)
if err := c.post(ctx, path, nil, nil, http.StatusNoContent); err != nil {
if err := c.post(ctx, path, req, nil, http.StatusNoContent); err != nil {
return fmt.Errorf("pausing target migration: %w", err)
}
return nil
}

// ResumeTargetMigration resumes a paused migration on the target side.
// POST /enterprise/migration/{id}/resume. Returns 204.
func (c *Client) ResumeTargetMigration(ctx context.Context, migrationID int64) error {
func (c *Client) ResumeTargetMigration(ctx context.Context, migrationID int64, req TargetMigrationTransitionRequest) error {
path := fmt.Sprintf("%s/%d/resume", targetMigrationBasePath, migrationID)
if err := c.post(ctx, path, nil, nil, http.StatusNoContent); err != nil {
if err := c.post(ctx, path, req, nil, http.StatusNoContent); err != nil {
return fmt.Errorf("resuming target migration: %w", err)
}
return nil
}

// AbortTargetMigration aborts a migration on the target side. This is a
// terminal action. POST /enterprise/migration/{id}/abort. Returns 204.
func (c *Client) AbortTargetMigration(ctx context.Context, migrationID int64) error {
func (c *Client) AbortTargetMigration(ctx context.Context, migrationID int64, req TargetMigrationTransitionRequest) error {
path := fmt.Sprintf("%s/%d/abort", targetMigrationBasePath, migrationID)
if err := c.post(ctx, path, nil, nil, http.StatusNoContent); err != nil {
if err := c.post(ctx, path, req, nil, http.StatusNoContent); err != nil {
return fmt.Errorf("aborting target migration: %w", err)
}
return nil
Expand Down
57 changes: 53 additions & 4 deletions internal/elmapi/target_migrations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,29 @@ import (
"net/url"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const testTargetOperationID = "0f8fad5b-d9cb-469f-a165-70867728950e"

func TestNewTargetMigrationOperationID(t *testing.T) {
first := NewTargetMigrationOperationID()
second := NewTargetMigrationOperationID()

require.NoError(t, uuid.Validate(first))
require.NoError(t, uuid.Validate(second))
assert.NotEqual(t, first, second)
}

func TestNewTargetMigrationTransitionRequest(t *testing.T) {
req := NewTargetMigrationTransitionRequest()

assert.Equal(t, TargetMigrationInitiatorCustomer, req.Initiator)
require.NoError(t, uuid.Validate(req.OperationID))
}

func TestCreateTargetMigration(t *testing.T) {
t.Run("sends the request body and decodes the raw response", func(t *testing.T) {
var gotPath, gotMethod string
Expand All @@ -31,6 +50,8 @@ func TestCreateTargetMigration(t *testing.T) {
Repositories: []string{"octo/repo"},
Description: "test migration",
ExporterMigrationGUID: "11111111-1111-1111-1111-111111111111",
Initiator: TargetMigrationInitiatorCustomer,
OperationID: testTargetOperationID,
})
require.NoError(t, err, "CreateTargetMigration")

Expand All @@ -39,6 +60,9 @@ func TestCreateTargetMigration(t *testing.T) {
assert.Equal(t, "https://source.example/octo/repo", gotBody["source_url"])
assert.Equal(t, []any{"octo/repo"}, gotBody["repositories"])
assert.Equal(t, "test migration", gotBody["description"])
assert.Equal(t, TargetMigrationInitiatorCustomer, gotBody["initiator"])
assert.Equal(t, testTargetOperationID, gotBody["operation_id"])
assert.NotContains(t, gotBody, "actor")
assert.JSONEq(t, `{"migrationId":"42","expiresAt":"2024-01-01T00:00:00Z"}`, string(raw))
})

Expand Down Expand Up @@ -303,46 +327,64 @@ func TestGetTargetMigrationStatus(t *testing.T) {
func TestPauseResumeAbortTargetMigration(t *testing.T) {
t.Run("pause posts to the pause path and expects 204", func(t *testing.T) {
var gotPath, gotMethod string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

c := NewClient(srv.URL, "tok")
err := c.PauseTargetMigration(t.Context(), 42)
err := c.PauseTargetMigration(t.Context(), 42, targetTransitionRequestForTest())
require.NoError(t, err, "PauseTargetMigration")
assert.Equal(t, http.MethodPost, gotMethod)
assert.Equal(t, "/enterprise/migration/42/pause", gotPath)
assert.Equal(t, map[string]any{
"initiator": TargetMigrationInitiatorCustomer,
"operation_id": testTargetOperationID,
}, gotBody)
})

t.Run("resume posts to the resume path and expects 204", func(t *testing.T) {
var gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

c := NewClient(srv.URL, "tok")
err := c.ResumeTargetMigration(t.Context(), 42)
err := c.ResumeTargetMigration(t.Context(), 42, targetTransitionRequestForTest())
require.NoError(t, err, "ResumeTargetMigration")
assert.Equal(t, "/enterprise/migration/42/resume", gotPath)
assert.Equal(t, map[string]any{
"initiator": TargetMigrationInitiatorCustomer,
"operation_id": testTargetOperationID,
}, gotBody)
})

t.Run("abort posts to the abort path and expects 204", func(t *testing.T) {
var gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

c := NewClient(srv.URL, "tok")
err := c.AbortTargetMigration(t.Context(), 42)
err := c.AbortTargetMigration(t.Context(), 42, targetTransitionRequestForTest())
require.NoError(t, err, "AbortTargetMigration")
assert.Equal(t, "/enterprise/migration/42/abort", gotPath)
assert.Equal(t, map[string]any{
"initiator": TargetMigrationInitiatorCustomer,
"operation_id": testTargetOperationID,
}, gotBody)
})

t.Run("returns HTTPError with 412 on precondition failed", func(t *testing.T) {
Expand All @@ -352,10 +394,17 @@ func TestPauseResumeAbortTargetMigration(t *testing.T) {
defer srv.Close()

c := NewClient(srv.URL, "tok")
err := c.PauseTargetMigration(t.Context(), 1)
err := c.PauseTargetMigration(t.Context(), 1, targetTransitionRequestForTest())
require.Error(t, err)
var httpErr *HTTPError
require.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusPreconditionFailed, httpErr.StatusCode)
})
}

func targetTransitionRequestForTest() TargetMigrationTransitionRequest {
return TargetMigrationTransitionRequest{
Initiator: TargetMigrationInitiatorCustomer,
OperationID: testTargetOperationID,
}
}
Loading