diff --git a/.changeset/bold-oak-redeploy.md b/.changeset/bold-oak-redeploy.md new file mode 100644 index 0000000000..0097b60051 --- /dev/null +++ b/.changeset/bold-oak-redeploy.md @@ -0,0 +1,8 @@ +--- +"dashboard": patch +"cli": patch +--- + +feat(cli): add `gram redeploy` command to clone and redeploy existing deployments + +fix(dashboard): show redeploy button on every deployment detail page and add visible Deployments navigation to sources page diff --git a/cli/internal/api/deployments.go b/cli/internal/api/deployments.go index 820910ffea..7fea5ce309 100644 --- a/cli/internal/api/deployments.go +++ b/cli/internal/api/deployments.go @@ -209,3 +209,24 @@ func (c *DeploymentsClient) Evolve( return result, nil } + +// Redeploy triggers a redeployment of an existing deployment. +func (c *DeploymentsClient) Redeploy( + ctx context.Context, + apiKey secret.Secret, + projectSlug string, + deploymentID string, +) (*types.Deployment, error) { + key := apiKey.Reveal() + result, err := c.client.Redeploy(ctx, &deployments.RedeployPayload{ + ApikeyToken: &key, + ProjectSlugInput: &projectSlug, + DeploymentID: deploymentID, + SessionToken: nil, + }) + if err != nil { + return nil, fmt.Errorf("api error: %w", err) + } + + return result.Deployment, nil +} diff --git a/cli/internal/app/app.go b/cli/internal/app/app.go index f84f670b82..a0fcf109b7 100644 --- a/cli/internal/app/app.go +++ b/cli/internal/app/app.go @@ -35,6 +35,7 @@ func newApp() *cli.App { newStageCommand(), newInstallCommand(), newUpdateCommand(), + newRedeployCommand(), }, Flags: []cli.Flag{ flags.APIKey(), diff --git a/cli/internal/app/redeploy.go b/cli/internal/app/redeploy.go new file mode 100644 index 0000000000..02c36acea6 --- /dev/null +++ b/cli/internal/app/redeploy.go @@ -0,0 +1,132 @@ +package app + +import ( + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/speakeasy-api/gram/cli/internal/app/logging" + "github.com/speakeasy-api/gram/cli/internal/flags" + "github.com/speakeasy-api/gram/cli/internal/profile" + "github.com/speakeasy-api/gram/cli/internal/workflow" + "github.com/urfave/cli/v2" +) + +func newRedeployCommand() *cli.Command { + return &cli.Command{ + Name: "redeploy", + Usage: "Redeploy an existing deployment", + Description: ` +Redeploy an existing deployment by cloning it with the same assets. + +If no deployment ID is provided, redeploys the latest deployment.`, + Flags: []cli.Flag{ + flags.APIEndpoint(), + flags.APIKey(), + flags.Project(), + flags.Org(), + &cli.StringFlag{ + Name: "id", + Usage: "The deployment ID to redeploy (if not provided, redeploys the latest deployment)", + }, + &cli.BoolFlag{ + Name: "skip-poll", + Usage: "Skip polling for deployment completion and return immediately", + Value: false, + }, + flags.JSON(), + }, + Action: func(c *cli.Context) error { + ctx, cancel := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM) + defer cancel() + + logger := logging.PullLogger(ctx) + prof := profile.FromContext(ctx) + deploymentID := c.String("id") + skipPoll := c.Bool("skip-poll") + jsonOutput := c.Bool("json") + + workflowParams, err := workflow.ResolveParams(c, prof) + if err != nil { + return fmt.Errorf("failed to resolve workflow params: %w", err) + } + + wf := workflow.New(ctx, logger, workflowParams) + + // Load the target deployment + if deploymentID != "" { + wf.LoadDeploymentByID(ctx, deploymentID) + } else { + wf.LoadLatestDeployment(ctx) + } + if wf.Failed() { + return fmt.Errorf("failed to load deployment: %w", wf.Err) + } + + originalID := wf.Deployment.ID + logger.InfoContext(ctx, "Redeploying deployment", slog.String("deployment_id", originalID)) + + // Trigger the redeploy + wf.RedeployDeployment(ctx) + if wf.Failed() { + return fmt.Errorf("failed to redeploy: %w", wf.Err) + } + + newID := wf.Deployment.ID + logger.InfoContext(ctx, + "New deployment created", + slog.String("deployment_id", newID), + slog.String("cloned_from", originalID), + ) + + // Poll for completion + if !skipPoll { + wf.Poll(ctx) + if wf.Failed() { + return fmt.Errorf("deployment polling failed: %w", wf.Err) + } + } + + // Output result + if jsonOutput { + return printDeploymentStatusJSON(wf.Deployment) + } + + logsURL := fmt.Sprintf("%s://%s/%s/%s/deployments/%s", + workflowParams.APIURL.Scheme, + workflowParams.APIURL.Host, + workflowParams.OrgSlug, + workflowParams.ProjectSlug, + newID, + ) + + switch wf.Deployment.Status { + case "completed": + logger.InfoContext(ctx, "Deployment succeeded", + slog.String("deployment_id", newID), + slog.String("logs_url", logsURL), + ) + fmt.Printf("\nView deployment: %s\n", logsURL) + openDeploymentURL(logger, ctx, logsURL) + case "failed": + logger.ErrorContext(ctx, "Deployment failed", + slog.String("deployment_id", newID), + slog.String("logs_url", logsURL), + ) + fmt.Printf("\nView deployment logs: %s\n", logsURL) + openDeploymentURL(logger, ctx, logsURL) + return fmt.Errorf("deployment failed") + default: + logger.InfoContext(ctx, "Deployment is still in progress", + slog.String("deployment_id", newID), + slog.String("status", wf.Deployment.Status), + ) + fmt.Printf("\nView deployment: %s\n", logsURL) + } + + return nil + }, + } +} diff --git a/cli/internal/workflow/workflow.go b/cli/internal/workflow/workflow.go index e7782059c5..e00df3371e 100644 --- a/cli/internal/workflow/workflow.go +++ b/cli/internal/workflow/workflow.go @@ -287,6 +287,29 @@ func (s *Workflow) LoadActiveDeployment( return s } +func (s *Workflow) RedeployDeployment(ctx context.Context) *Workflow { + if s.Failed() { + return s + } + + if s.Deployment == nil { + return s.Fail(fmt.Errorf("redeploy failed: no deployment loaded")) + } + + result, err := s.DeploymentsClient.Redeploy( + ctx, + s.Params.APIKey, + s.Params.ProjectSlug, + s.Deployment.ID, + ) + if err != nil { + return s.Fail(fmt.Errorf("redeploy deployment '%s': %w", s.Deployment.ID, err)) + } + + s.Deployment = result + return s +} + func (s *Workflow) ListToolsets(ctx context.Context) *Workflow { if s.Failed() { return s diff --git a/client/dashboard/src/components/sources/Sources.tsx b/client/dashboard/src/components/sources/Sources.tsx index 4e115f855a..3c43a87d42 100644 --- a/client/dashboard/src/components/sources/Sources.tsx +++ b/client/dashboard/src/components/sources/Sources.tsx @@ -1,10 +1,7 @@ import { Page } from "@/components/page-layout"; -import { SimpleTooltip } from "@/components/ui/tooltip"; import { useSdkClient } from "@/contexts/Sdk"; import { useTelemetry } from "@/contexts/Telemetry"; -import { cn } from "@/lib/utils"; import { useInfiniteListMCPCatalog } from "@/pages/catalog/hooks"; -import { useDeploymentLogsSummary } from "@/pages/deployments/deployment/Deployment"; import { useRoutes } from "@/routes"; import { useLatestDeployment, @@ -106,7 +103,6 @@ export default function Sources() { const deployment = deploymentResult?.deployment; const assetsCausingFailure = useUnusedAssetIds(); - const failedDeployment = useFailedDeploymentSources(); const { dialogState, openRemoveSource, @@ -249,26 +245,15 @@ export default function Sources() { return ( <> - - - Sources - {failedDeployment.hasFailures && failedDeployment.deployment && ( - - - Deployment errors - - )} - - + Sources {isFunctionsEnabled ? "OpenAPI documents, Gram Functions, and third-party MCP servers providing tools for your project" : "OpenAPI documents and third-party MCP servers providing tools for your project"} - + + + @@ -433,46 +418,29 @@ const useUnusedAssetIds = () => { function DeploymentsButton({ deploymentId }: { deploymentId?: string }) { const routes = useRoutes(); - const { data: deploymentResult } = useLatestDeployment(); - const deployment = deploymentResult?.deployment; - const deploymentLogsSummary = useDeploymentLogsSummary(deploymentId); - - const hasErrors = deploymentLogsSummary && deploymentLogsSummary.errors > 0; - const deploymentFailed = deployment?.status === "failed"; - - const icon = hasErrors ? ( - - ) : ( - - ); - - let tooltip = "View deployment history"; - if (deployment && deploymentLogsSummary) { - tooltip = deploymentFailed - ? "Latest deployment failed" - : "Latest deployment succeeded"; + const failedDeployment = useFailedDeploymentSources(); - if (deploymentLogsSummary.skipped > 0) { - tooltip += ` (${deploymentLogsSummary.skipped} operations skipped)`; - } + if (failedDeployment.hasFailures && deploymentId) { + return ( + + + + ); } return ( - - - - - - - + + + ); } diff --git a/client/dashboard/src/pages/deployments/deployment/Deployment.tsx b/client/dashboard/src/pages/deployments/deployment/Deployment.tsx index 0adc95ab6d..88ac57644e 100644 --- a/client/dashboard/src/pages/deployments/deployment/Deployment.tsx +++ b/client/dashboard/src/pages/deployments/deployment/Deployment.tsx @@ -161,7 +161,10 @@ const HeadingSection = () => { } else if (deployment.status === "completed") { if (isPending) buttonText = "Rolling Back..."; else buttonText = "Roll Back"; - } else return null; + } else { + if (isPending) buttonText = "Redeploying..."; + else buttonText = "Redeploy"; + } return (