Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
21 changes: 21 additions & 0 deletions cli/internal/api/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions cli/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func newApp() *cli.App {
newStageCommand(),
newInstallCommand(),
newUpdateCommand(),
newRedeployCommand(),
},
Flags: []cli.Flag{
flags.APIKey(),
Expand Down
132 changes: 132 additions & 0 deletions cli/internal/app/redeploy.go
Original file line number Diff line number Diff line change
@@ -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
},
}
}
23 changes: 23 additions & 0 deletions cli/internal/workflow/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 23 additions & 52 deletions client/dashboard/src/components/sources/Sources.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -249,20 +246,7 @@ export default function Sources() {
return (
<>
<Page.Section>
<Page.Section.Title>
<span className="inline-flex items-center gap-2">
Sources
{failedDeployment.hasFailures && failedDeployment.deployment && (
<routes.deployments.deployment.Link
params={[failedDeployment.deployment.id]}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-sm font-medium text-destructive bg-destructive/10 hover:bg-destructive/20 transition-colors"
>
<CircleAlert className="size-4" />
<span>Deployment errors</span>
</routes.deployments.deployment.Link>
)}
</span>
</Page.Section.Title>
<Page.Section.Title>Sources</Page.Section.Title>
<Page.Section.Description>
{isFunctionsEnabled
? "OpenAPI documents, Gram Functions, and third-party MCP servers providing tools for your project"
Expand Down Expand Up @@ -433,46 +417,33 @@ 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 ? (
<Icon name="triangle-alert" className="text-yellow-500" />
) : (
<Icon name="history" className="text-muted-foreground" />
);

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 (
<Page.Section.CTA>
<a href={routes.deployments.deployment.href(deploymentId)}>
<Button variant="secondary" className="text-destructive">
<Button.LeftIcon>
<CircleAlert className="w-4 h-4" />
</Button.LeftIcon>
<Button.Text>Deployment Errors</Button.Text>
</Button>
</a>
</Page.Section.CTA>
);
}

return (
<Page.Section.CTA>
<SimpleTooltip tooltip={tooltip}>
<a href={routes.deployments.href()}>
<Button
variant="tertiary"
className={cn(
hasErrors &&
"text-yellow-600 dark:text-yellow-500 hover:bg-yellow-500/20!",
)}
>
<Button.LeftIcon>{icon}</Button.LeftIcon>
Deployments
</Button>
</a>
</SimpleTooltip>
<a href={routes.deployments.href()}>
<Button variant="secondary">
<Button.LeftIcon>
<Icon name="history" />
</Button.LeftIcon>
<Button.Text>Deployments</Button.Text>
</Button>
</a>
</Page.Section.CTA>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ const HeadingSection = () => {
} else if (deployment.status === "completed") {
if (isPending) buttonText = "Rolling Back...";
else buttonText = "Roll Back";
} else if (deployment.status === "failed") {
if (isPending) buttonText = "Retrying Deployment";
else buttonText = "Retry Deployment";
} else return null;

return (
Expand Down
Loading