diff --git a/.changeset/lovely-shrimps-show.md b/.changeset/lovely-shrimps-show.md new file mode 100644 index 000000000..7b03e28aa --- /dev/null +++ b/.changeset/lovely-shrimps-show.md @@ -0,0 +1,5 @@ +--- +"@gram/cli": minor +--- + +Enable asset upload to gram via `gram upload` diff --git a/cli/go.mod b/cli/go.mod index 796e15028..675292c92 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -7,6 +7,7 @@ replace github.com/speakeasy-api/gram/server => ../server require ( github.com/BurntSushi/toml v1.5.0 github.com/charmbracelet/log v0.4.2 + github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/speakeasy-api/gram/server v0.0.0-00010101000000-000000000000 github.com/stretchr/testify v1.11.1 @@ -27,7 +28,6 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-chi/chi/v5 v5.2.3 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect diff --git a/cli/internal/api/assets.go b/cli/internal/api/assets.go index 64b966c1f..2e525542f 100644 --- a/cli/internal/api/assets.go +++ b/cli/internal/api/assets.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "log/slog" "github.com/speakeasy-api/gram/server/gen/assets" assets_client "github.com/speakeasy-api/gram/server/gen/http/assets/client" @@ -51,7 +50,6 @@ type UploadOpenAPIv3Request struct { func (c *AssetsClient) UploadOpenAPIv3( ctx context.Context, - logger *slog.Logger, req *UploadOpenAPIv3Request, ) (*assets.UploadOpenAPIv3Result, error) { payload := &assets.UploadOpenAPIv3Form{ diff --git a/cli/internal/api/deployments.go b/cli/internal/api/deployments.go index 8668ed3e2..7594d300b 100644 --- a/cli/internal/api/deployments.go +++ b/cli/internal/api/deployments.go @@ -4,9 +4,11 @@ import ( "context" "fmt" + "github.com/google/uuid" "github.com/speakeasy-api/gram/cli/internal/secret" "github.com/speakeasy-api/gram/server/gen/deployments" depl_client "github.com/speakeasy-api/gram/server/gen/http/deployments/client" + "github.com/speakeasy-api/gram/server/gen/types" goahttp "goa.design/goa/v3/http" ) @@ -55,7 +57,10 @@ func (c *DeploymentsClient) CreateDeployment( req CreateDeploymentRequest, ) (*deployments.CreateDeploymentResult, error) { key := req.APIKey.Reveal() - result, err := c.client.CreateDeployment(ctx, &deployments.CreateDeploymentPayload{ + if req.IdempotencyKey == "" { + req.IdempotencyKey = uuid.New().String() + } + payload := &deployments.CreateDeploymentPayload{ ApikeyToken: &key, ProjectSlugInput: &req.ProjectSlug, IdempotencyKey: req.IdempotencyKey, @@ -68,7 +73,8 @@ func (c *DeploymentsClient) CreateDeployment( ExternalID: nil, ExternalURL: nil, Packages: nil, - }) + } + result, err := c.client.CreateDeployment(ctx, payload) if err != nil { return nil, fmt.Errorf("failed to create deployment: %w", err) } @@ -82,7 +88,7 @@ func (c *DeploymentsClient) GetDeployment( apiKey secret.Secret, projectSlug string, deploymentID string, -) (*deployments.GetDeploymentResult, error) { +) (*types.Deployment, error) { key := apiKey.Reveal() result, err := c.client.GetDeployment(ctx, &deployments.GetDeploymentPayload{ ApikeyToken: &key, @@ -94,7 +100,26 @@ func (c *DeploymentsClient) GetDeployment( return nil, fmt.Errorf("failed to get deployment: %w", err) } - return result, nil + return &types.Deployment{ + ID: result.ID, + OrganizationID: result.OrganizationID, + ProjectID: result.ProjectID, + UserID: result.UserID, + CreatedAt: result.CreatedAt, + Status: result.Status, + IdempotencyKey: result.IdempotencyKey, + GithubRepo: result.GithubRepo, + GithubPr: result.GithubPr, + GithubSha: result.GithubSha, + ExternalID: result.ExternalID, + ExternalURL: result.ExternalURL, + ClonedFrom: result.ClonedFrom, + Openapiv3ToolCount: result.Openapiv3ToolCount, + Openapiv3Assets: result.Openapiv3Assets, + FunctionsToolCount: result.FunctionsToolCount, + FunctionsAssets: result.FunctionsAssets, + Packages: result.Packages, + }, nil } // GetLatestDeployment retrieves the latest deployment for a project. @@ -102,16 +127,74 @@ func (c *DeploymentsClient) GetLatestDeployment( ctx context.Context, apiKey secret.Secret, projectSlug string, -) (*deployments.GetLatestDeploymentResult, error) { +) (*types.Deployment, error) { key := apiKey.Reveal() - result, err := c.client.GetLatestDeployment(ctx, &deployments.GetLatestDeploymentPayload{ - ApikeyToken: &key, - ProjectSlugInput: &projectSlug, - SessionToken: nil, - }) + result, err := c.client.GetLatestDeployment( + ctx, + &deployments.GetLatestDeploymentPayload{ + ApikeyToken: &key, + ProjectSlugInput: &projectSlug, + SessionToken: nil, + }, + ) if err != nil { return nil, fmt.Errorf("failed to get latest deployment: %w", err) } + return result.Deployment, nil +} + +// GetActiveDeployment retrieves the active deployment for a project. +func (c *DeploymentsClient) GetActiveDeployment( + ctx context.Context, + apiKey secret.Secret, + projectSlug string, +) (*types.Deployment, error) { + key := apiKey.Reveal() + result, err := c.client.GetActiveDeployment( + ctx, + &deployments.GetActiveDeploymentPayload{ + ApikeyToken: &key, + ProjectSlugInput: &projectSlug, + SessionToken: nil, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to get active deployment: %w", err) + } + + return result.Deployment, nil +} + +// EvolveRequest lists the assets to add to a deployment. +type EvolveRequest struct { + Assets []*deployments.AddOpenAPIv3DeploymentAssetForm + APIKey secret.Secret + DeploymentID string + ProjectSlug string +} + +// Evolve adds assets to an existing deployment. +func (c *DeploymentsClient) Evolve( + ctx context.Context, + req EvolveRequest, +) (*deployments.EvolveResult, error) { + key := req.APIKey.Reveal() + result, err := c.client.Evolve(ctx, &deployments.EvolvePayload{ + ApikeyToken: &key, + ProjectSlugInput: &req.ProjectSlug, + DeploymentID: &req.DeploymentID, + UpsertOpenapiv3Assets: req.Assets, + UpsertFunctions: []*deployments.AddFunctionsForm{}, + ExcludeOpenapiv3Assets: []string{}, + ExcludeFunctions: []string{}, + ExcludePackages: []string{}, + UpsertPackages: []*deployments.AddPackageForm{}, + SessionToken: nil, + }) + if err != nil { + return nil, fmt.Errorf("failed to evolve deployment: %w", err) + } + return result, nil } diff --git a/cli/internal/app/app.go b/cli/internal/app/app.go index 10b7e8157..900951062 100644 --- a/cli/internal/app/app.go +++ b/cli/internal/app/app.go @@ -8,6 +8,7 @@ import ( "github.com/urfave/cli/v2" + "github.com/speakeasy-api/gram/cli/internal/app/logging" "github.com/speakeasy-api/gram/cli/internal/o11y" ) @@ -23,6 +24,7 @@ func newApp() *cli.App { Version: fmt.Sprintf("%s (%s)", Version, shortSha), Commands: []*cli.Command{ newPushCommand(), + newUploadCommand(), newStatusCommand(), }, Flags: []cli.Flag{ @@ -48,11 +50,11 @@ func newApp() *cli.App { Before: func(c *cli.Context) error { logger := slog.New(o11y.NewLogHandler(&o11y.LogHandlerOptions{ RawLevel: c.String("log-level"), - Pretty: c.Bool("pretty"), + Pretty: c.Bool("log-pretty"), DataDogAttr: true, })) - ctx := PushLogger(c.Context, logger) + ctx := logging.PushLogger(c.Context, logger) c.Context = ctx return nil }, diff --git a/cli/internal/app/logging.go b/cli/internal/app/logging/logging.go similarity index 96% rename from cli/internal/app/logging.go rename to cli/internal/app/logging/logging.go index d0b171a12..657ffdc36 100644 --- a/cli/internal/app/logging.go +++ b/cli/internal/app/logging/logging.go @@ -1,4 +1,4 @@ -package app +package logging import ( "context" diff --git a/cli/internal/app/push.go b/cli/internal/app/push.go index 1d58f8a38..6eb173879 100644 --- a/cli/internal/app/push.go +++ b/cli/internal/app/push.go @@ -1,7 +1,6 @@ package app import ( - "context" "fmt" "log/slog" "net/url" @@ -9,13 +8,12 @@ import ( "os/signal" "path/filepath" "syscall" - "time" - "github.com/speakeasy-api/gram/cli/internal/api" + "github.com/speakeasy-api/gram/cli/internal/app/logging" "github.com/speakeasy-api/gram/cli/internal/deploy" + "github.com/speakeasy-api/gram/cli/internal/flags" "github.com/speakeasy-api/gram/cli/internal/o11y" "github.com/speakeasy-api/gram/cli/internal/secret" - "github.com/speakeasy-api/gram/server/gen/types" "github.com/urfave/cli/v2" ) @@ -43,24 +41,9 @@ Sample deployment file NOTE: Names and slugs must be unique across all sources.`[1:], Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "api-url", - Usage: "The base URL to use for API calls.", - EnvVars: []string{"GRAM_API_URL"}, - Value: "https://app.getgram.ai", - }, - &cli.StringFlag{ - Name: "api-key", - Usage: "Your Gram API key (must be scoped as a 'Provider')", - EnvVars: []string{"GRAM_API_KEY"}, - Required: true, - }, - &cli.StringFlag{ - Name: "project", - Usage: "The Gram project to push to", - EnvVars: []string{"GRAM_PROJECT"}, - Required: true, - }, + flags.APIEndpoint(), + flags.APIKey(), + flags.Project(), &cli.PathFlag{ Name: "config", Usage: "Path to the deployment file", @@ -81,27 +64,18 @@ NOTE: Names and slugs must be unique across all sources.`[1:], ctx, cancel := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM) defer cancel() - logger := PullLogger(ctx) + logger := logging.PullLogger(ctx) projectSlug := c.String("project") - apiURLArg := c.String("api-url") - apiURL, err := url.Parse(apiURLArg) + apiURL, err := url.Parse(c.String("api-url")) if err != nil { - return fmt.Errorf("failed to parse API URL '%s': %w", apiURLArg, err) - } - if apiURL.Scheme == "" || apiURL.Host == "" { - return fmt.Errorf("API URL '%s' must include scheme and host", apiURLArg) + return fmt.Errorf( + "failed to parse API URL '%s': %w", + c.String("api-url"), + err, + ) } - assetsClient := api.NewAssetsClient(&api.AssetsClientOptions{ - Scheme: apiURL.Scheme, - Host: apiURL.Host, - }) - deploymentsClient := api.NewDeploymentsClient(&api.DeploymentsClientOptions{ - Scheme: apiURL.Scheme, - Host: apiURL.Host, - }) - configFilename, err := filepath.Abs(c.String("config")) if err != nil { return fmt.Errorf("failed to resolve deployment file path: %w", err) @@ -120,115 +94,65 @@ NOTE: Names and slugs must be unique across all sources.`[1:], return fmt.Errorf("failed to parseread deployment config: %w", err) } - logger.InfoContext(ctx, "Deploying to project", slog.String("project", projectSlug), slog.String("config", c.String("config"))) - - req := deploy.CreateDeploymentRequest{ - Config: config, - APIKey: secret.Secret(c.String("api-key")), - ProjectSlug: projectSlug, - IdempotencyKey: c.String("idempotency-key"), - } - result, err := deploy.CreateDeployment(ctx, logger, assetsClient, deploymentsClient, req) - if err != nil { - return fmt.Errorf("deployment failed: %w", err) + logger.InfoContext( + ctx, + "Deploying to project", + slog.String("project", projectSlug), + slog.String("config", c.String("config")), + ) + + params := deploy.WorkflowParams{ + APIKey: secret.Secret(c.String("api-key")), + APIURL: apiURL, + ProjectSlug: projectSlug, } + result := deploy.NewWorkflow(ctx, logger, params). + UploadAssets(ctx, config.Sources). + CreateDeployment(ctx, c.String("idempotency-key")) - logger.InfoContext(ctx, "Deployment has begun processing", slog.Any("id", result.Deployment.ID)) - - if c.Bool("skip-poll") { - logger.InfoContext(ctx, "Skipping deployment status polling", slog.String("deployment_id", result.Deployment.ID)) - logger.InfoContext(ctx, "You can check deployment status with", slog.String("command", fmt.Sprintf("gram status --id %s", result.Deployment.ID))) - return nil + if !c.Bool("skip-poll") { + result.Poll(ctx) } - deploymentResult, err := pollDeploymentStatus(ctx, logger, deploymentsClient, req.APIKey, req.ProjectSlug, result.Deployment.ID) - if err != nil { - logger.WarnContext(ctx, "Failed to poll deployment status", slog.String("error", err.Error())) - logger.InfoContext(ctx, "You can check deployment status with", slog.String("command", fmt.Sprintf("gram status %s", result.Deployment.ID))) - return nil + if result.Failed() { + if result.Deployment != nil { + statusCommand := fmt.Sprintf( + "gram status --id %s", + result.Deployment.ID, + ) + + result.Logger.WarnContext( + ctx, + "Poll failed.", + slog.String("command", statusCommand), + slog.String("error", result.Err.Error()), + ) + return nil + } + + return fmt.Errorf("failed to push deploy: %w", result.Err) } - switch deploymentResult.Status { + slogID := slog.String("deployment_id", result.Deployment.ID) + status := result.Deployment.Status + + switch status { case "completed": - logger.InfoContext(ctx, "Deployment completed successfully", slog.String("deployment_id", deploymentResult.ID)) + logger.InfoContext(ctx, "Deployment succeeded", slogID) + return nil case "failed": - logger.ErrorContext(ctx, "Deployment failed", slog.String("deployment_id", deploymentResult.ID)) + logger.ErrorContext(ctx, "Deployment failed", slogID) return fmt.Errorf("deployment failed") default: - logger.InfoContext(ctx, "Deployment is still in progress", slog.String("status", deploymentResult.Status), slog.String("deployment_id", deploymentResult.ID)) + logger.InfoContext( + ctx, + "Deployment is still in progress", + slogID, + slog.String("status", status), + ) } return nil }, } } - -// pollDeploymentStatus polls for deployment status until it reaches a terminal -// state or times out -func pollDeploymentStatus( - ctx context.Context, - logger *slog.Logger, - client *api.DeploymentsClient, - apiKey secret.Secret, - projectSlug string, - deploymentID string, -) (*types.Deployment, error) { - ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - logger.InfoContext(ctx, "Polling deployment status...", slog.String("deployment_id", deploymentID)) - - for { - select { - case <-ctx.Done(): - if ctx.Err() == context.DeadlineExceeded { - return nil, fmt.Errorf("deployment polling timed out after 2 minutes") - } - return nil, fmt.Errorf("deployment polling cancelled: %w", ctx.Err()) - - case <-ticker.C: - result, err := client.GetDeployment(ctx, apiKey, projectSlug, deploymentID) - if err != nil { - return nil, fmt.Errorf("failed to get deployment status: %w", err) - } - - deployment := &types.Deployment{ - ID: result.ID, - OrganizationID: result.OrganizationID, - ProjectID: result.ProjectID, - UserID: result.UserID, - CreatedAt: result.CreatedAt, - Status: result.Status, - IdempotencyKey: result.IdempotencyKey, - GithubRepo: result.GithubRepo, - GithubPr: result.GithubPr, - GithubSha: result.GithubSha, - ExternalID: result.ExternalID, - ExternalURL: result.ExternalURL, - ClonedFrom: result.ClonedFrom, - Openapiv3ToolCount: result.Openapiv3ToolCount, - Openapiv3Assets: result.Openapiv3Assets, - FunctionsToolCount: result.FunctionsToolCount, - FunctionsAssets: result.FunctionsAssets, - Packages: result.Packages, - } - - logger.DebugContext(ctx, "Deployment status check", - slog.String("deployment_id", deploymentID), - slog.String("status", deployment.Status)) - - switch deployment.Status { - case "completed", "failed": - return deployment, nil - case "pending": - continue - default: - logger.WarnContext(ctx, "Unknown deployment status", slog.String("status", deployment.Status)) - continue - } - } - } -} diff --git a/cli/internal/app/status.go b/cli/internal/app/status.go index ecf19272e..60b03712d 100644 --- a/cli/internal/app/status.go +++ b/cli/internal/app/status.go @@ -3,13 +3,14 @@ package app import ( "encoding/json" "fmt" - "log/slog" "net/url" "os" "os/signal" "syscall" - "github.com/speakeasy-api/gram/cli/internal/api" + "github.com/speakeasy-api/gram/cli/internal/app/logging" + "github.com/speakeasy-api/gram/cli/internal/deploy" + "github.com/speakeasy-api/gram/cli/internal/flags" "github.com/speakeasy-api/gram/cli/internal/secret" "github.com/speakeasy-api/gram/server/gen/types" "github.com/urfave/cli/v2" @@ -24,24 +25,9 @@ Check the status of a deployment. If no deployment ID is provided, shows the status of the latest deployment.`, Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "api-url", - Usage: "The base URL to use for API calls.", - EnvVars: []string{"GRAM_API_URL"}, - Value: "https://app.getgram.ai", - }, - &cli.StringFlag{ - Name: "api-key", - Usage: "Your Gram API key (must be scoped as a 'Provider')", - EnvVars: []string{"GRAM_API_KEY"}, - Required: true, - }, - &cli.StringFlag{ - Name: "project", - Usage: "The Gram project to check status for", - EnvVars: []string{"GRAM_PROJECT"}, - Required: true, - }, + flags.APIEndpoint(), + flags.APIKey(), + flags.Project(), &cli.StringFlag{ Name: "id", Usage: "The deployment ID to check status for (if not provided, shows latest deployment)", @@ -55,82 +41,53 @@ If no deployment ID is provided, shows the status of the latest deployment.`, ctx, cancel := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM) defer cancel() - logger := PullLogger(ctx) + logger := logging.PullLogger(ctx) projectSlug := c.String("project") deploymentID := c.String("id") jsonOutput := c.Bool("json") - apiURLArg := c.String("api-url") - apiURL, err := url.Parse(apiURLArg) + apiURL, err := url.Parse(c.String("api-url")) if err != nil { - return fmt.Errorf("failed to parse API URL '%s': %w", apiURLArg, err) + return fmt.Errorf( + "failed to parse API URL '%s': %w", + c.String("api-url"), + err, + ) } - if apiURL.Scheme == "" || apiURL.Host == "" { - return fmt.Errorf("API URL '%s' must include scheme and host", apiURLArg) - } - - deploymentsClient := api.NewDeploymentsClient(&api.DeploymentsClientOptions{ - Scheme: apiURL.Scheme, - Host: apiURL.Host, - }) - apiKey := secret.Secret(c.String("api-key")) + params := deploy.WorkflowParams{ + APIKey: secret.Secret(c.String("api-key")), + APIURL: apiURL, + ProjectSlug: projectSlug, + } + result := deploy.NewWorkflow(ctx, logger, params) - var deployment *types.Deployment if deploymentID != "" { - logger.InfoContext(ctx, "Getting deployment status", slog.String("deployment_id", deploymentID)) - result, err := deploymentsClient.GetDeployment(ctx, apiKey, projectSlug, deploymentID) - if err != nil { - return fmt.Errorf("failed to get deployment: %w", err) - } - deployment = &types.Deployment{ - ID: result.ID, - OrganizationID: result.OrganizationID, - ProjectID: result.ProjectID, - UserID: result.UserID, - CreatedAt: result.CreatedAt, - Status: result.Status, - IdempotencyKey: result.IdempotencyKey, - GithubRepo: result.GithubRepo, - GithubPr: result.GithubPr, - GithubSha: result.GithubSha, - ExternalID: result.ExternalID, - ExternalURL: result.ExternalURL, - ClonedFrom: result.ClonedFrom, - Openapiv3ToolCount: result.Openapiv3ToolCount, - Openapiv3Assets: result.Openapiv3Assets, - FunctionsToolCount: result.FunctionsToolCount, - FunctionsAssets: result.FunctionsAssets, - Packages: result.Packages, - } + result.LoadDeploymentByID(ctx, deploymentID) } else { - logger.InfoContext(ctx, "Getting latest deployment status") - result, err := deploymentsClient.GetLatestDeployment(ctx, apiKey, projectSlug) - if err != nil { - return fmt.Errorf("failed to get latest deployment: %w", err) - } - if result.Deployment == nil { - if jsonOutput { - fmt.Println("{}") - } else { - fmt.Println("No deployments found for this project") - } - return nil - } - deployment = result.Deployment + result.LoadLatestDeployment(ctx) + } + if result.Failed() { + return fmt.Errorf("failed to get status: %w", result.Err) } if jsonOutput { - return printDeploymentStatusJSON(deployment) + return printDeploymentStatusJSON(result.Deployment) } else { - printDeploymentStatus(deployment) + printDeploymentStatus(result.Deployment) } + return nil }, } } func printDeploymentStatus(deployment *types.Deployment) { + if deployment == nil { + fmt.Println("No deployments found for this project") + return + } + fmt.Printf("Deployment Status\n") fmt.Printf("=================\n\n") @@ -157,7 +114,7 @@ func printDeploymentStatus(deployment *types.Deployment) { fmt.Printf("External URL: %s\n", *deployment.ExternalURL) } - fmt.Printf("\nAssets:\n") + fmt.Printf("\nTools:\n") fmt.Printf(" OpenAPI Tools: %d\n", deployment.Openapiv3ToolCount) fmt.Printf(" Functions: %d\n", deployment.FunctionsToolCount) @@ -178,6 +135,11 @@ func printDeploymentStatus(deployment *types.Deployment) { } func printDeploymentStatusJSON(deployment *types.Deployment) error { + if deployment == nil { + fmt.Println("{}") + return nil + } + jsonData, err := json.MarshalIndent(deployment, "", " ") if err != nil { return fmt.Errorf("failed to marshal deployment to JSON: %w", err) diff --git a/cli/internal/app/upload.go b/cli/internal/app/upload.go new file mode 100644 index 000000000..725ff3363 --- /dev/null +++ b/cli/internal/app/upload.go @@ -0,0 +1,106 @@ +package app + +import ( + "fmt" + "log/slog" + "net/url" + "os" + "os/signal" + "syscall" + + "github.com/speakeasy-api/gram/cli/internal/app/logging" + "github.com/speakeasy-api/gram/cli/internal/deploy" + "github.com/speakeasy-api/gram/cli/internal/flags" + "github.com/speakeasy-api/gram/cli/internal/secret" + "github.com/urfave/cli/v2" +) + +func newUploadCommand() *cli.Command { + return &cli.Command{ + Name: "upload", + Usage: "Upload an asset to Gram", + Description: ` +Example: + gram upload --type openapiv3 \ + --location https://raw.githubusercontent.com/my/spec.yaml \ + --name "My API" \ + --slug my-api`[1:], + Flags: []cli.Flag{ + flags.APIEndpoint(), + flags.APIKey(), + flags.Project(), + &cli.StringFlag{ + Name: "type", + Usage: fmt.Sprintf("The type of asset to upload: %+v", deploy.AllowedTypes), + Required: true, + }, + &cli.StringFlag{ + Name: "location", + Usage: "The location of the asset (file path or URL)", + Required: true, + }, + &cli.StringFlag{ + Name: "name", + Usage: "The human-readable name of the asset", + Required: true, + }, + &cli.StringFlag{ + Name: "slug", + Usage: "The URL-friendly slug for the asset", + Required: true, + }, + }, + Action: func(c *cli.Context) error { + ctx, cancel := signal.NotifyContext( + c.Context, + os.Interrupt, + syscall.SIGTERM, + ) + defer cancel() + + logger := logging.PullLogger(ctx) + apiURL, err := url.Parse(c.String("api-url")) + if err != nil { + return fmt.Errorf( + "failed to parse API URL '%s': %w", + c.String("api-url"), + err, + ) + } + params := deploy.WorkflowParams{ + APIKey: secret.Secret(c.String("api-key")), + APIURL: apiURL, + ProjectSlug: c.String("project"), + } + + result := deploy.NewWorkflow(ctx, logger, params). + UploadAssets(ctx, []deploy.Source{parseSource(c)}). + LoadLatestDeployment(ctx) + if result.Deployment == nil { + result.CreateDeployment(ctx, "") + } else { + result.EvolveDeployment(ctx) + } + + if result.Failed() { + return fmt.Errorf("failed to upload: %w", result.Err) + } + + result.Logger.InfoContext( + ctx, + "upload success", + slog.String("deployment_id", result.Deployment.ID), + ) + return nil + }, + } +} + +func parseSource(c *cli.Context) deploy.Source { + return deploy.Source{ + Type: deploy.SourceType(c.String("type")), + Location: c.String("location"), + Name: c.String("name"), + Slug: c.String("slug"), + } +} diff --git a/cli/internal/deploy/config.go b/cli/internal/deploy/config.go index 9ce8e68db..1928315dd 100644 --- a/cli/internal/deploy/config.go +++ b/cli/internal/deploy/config.go @@ -25,6 +25,8 @@ const ( SourceTypeOpenAPIV3 SourceType = "openapiv3" ) +var AllowedTypes = []SourceType{SourceTypeOpenAPIV3} + type Source struct { Type SourceType `json:"type" yaml:"type" toml:"type"` @@ -57,7 +59,7 @@ func (s Source) Validate() error { } func isSupportedType(s Source) bool { - return s.Type == SourceTypeOpenAPIV3 + return slices.Contains(AllowedTypes, s.Type) } type Config struct { @@ -116,6 +118,14 @@ func NewConfig(cfgRdr io.Reader, filename string) (*Config, error) { return &cfg, nil } +func NewConfigFromSources(sources ...Source) *Config { + return &Config{ + SchemaVersion: validSchemaVersions[0], + Type: configTypeDeployment, + Sources: sources, + } +} + // Validate returns an error if the schema version is invalid, if the config // is missing sources, if sources have missing required fields, or if names/slugs are not unique. func (dc Config) Validate() error { diff --git a/cli/internal/deploy/deploy.go b/cli/internal/deploy/deploy.go deleted file mode 100644 index fd8b2b2ed..000000000 --- a/cli/internal/deploy/deploy.go +++ /dev/null @@ -1,49 +0,0 @@ -package deploy - -import ( - "context" - "fmt" - "log/slog" - - "github.com/speakeasy-api/gram/cli/internal/api" - "github.com/speakeasy-api/gram/cli/internal/secret" - "github.com/speakeasy-api/gram/server/gen/deployments" -) - -type CreateDeploymentRequest struct { - Config *Config - APIKey secret.Secret - ProjectSlug string - IdempotencyKey string -} - -// CreateDeployment creates a remote deployment from the incoming sources. -func CreateDeployment( - ctx context.Context, - logger *slog.Logger, - assetsClient *api.AssetsClient, - deploymentsClient *api.DeploymentsClient, - req CreateDeploymentRequest, -) (*deployments.CreateDeploymentResult, error) { - assets, err := createAssetsForDeployment(ctx, logger, assetsClient, &CreateDeploymentRequest{ - Config: req.Config, - APIKey: req.APIKey, - ProjectSlug: req.ProjectSlug, - IdempotencyKey: req.IdempotencyKey, - }) - if err != nil { - return nil, fmt.Errorf("failed to convert sources to assets: %w", err) - } - - result, err := deploymentsClient.CreateDeployment(ctx, api.CreateDeploymentRequest{ - APIKey: req.APIKey, - ProjectSlug: req.ProjectSlug, - IdempotencyKey: req.IdempotencyKey, - OpenAPIv3Assets: assets, - }) - if err != nil { - return nil, fmt.Errorf("deployment creation failed: %w", err) - } - - return result, nil -} diff --git a/cli/internal/deploy/read.go b/cli/internal/deploy/read.go index c5a4ed656..51f91641b 100644 --- a/cli/internal/deploy/read.go +++ b/cli/internal/deploy/read.go @@ -14,7 +14,7 @@ import ( // readLocal reads a source from a local file path. func (sr *SourceReader) readLocal() (io.ReadCloser, int64, error) { - f, err := os.Open(sr.source.Location) + f, err := os.Open(sr.Source.Location) if err != nil { return nil, 0, fmt.Errorf("failed to read local file: %w", err) } @@ -32,7 +32,7 @@ func (sr *SourceReader) readRemote(ctx context.Context) (io.ReadCloser, int64, e ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - req, err := http.NewRequestWithContext(ctx, "GET", sr.source.Location, nil) + req, err := http.NewRequestWithContext(ctx, "GET", sr.Source.Location, nil) if err != nil { return nil, 0, fmt.Errorf("failed to create request: %w", err) } diff --git a/cli/internal/deploy/source_reader.go b/cli/internal/deploy/source_reader.go index 13a82c13c..3440af5e4 100644 --- a/cli/internal/deploy/source_reader.go +++ b/cli/internal/deploy/source_reader.go @@ -16,31 +16,31 @@ const ( // SourceReader reads source content from local files or remote URLs. type SourceReader struct { - source Source + Source Source } // NewSourceReader creates a new SourceReader for the given source. func NewSourceReader(source Source) *SourceReader { return &SourceReader{ - source: source, + Source: source, } } // GetContentType returns the MIME type of the content based on file extension. func (sr *SourceReader) GetContentType() string { - if isRemoteURL(sr.source.Location) { + if isRemoteURL(sr.Source.Location) { // NOTE(cjea): For remote URLs, we'll need to determine content type // differently. For now, default to common OpenAPI types based on // extension. - return getContentTypeFromPath(sr.source.Location) + return getContentTypeFromPath(sr.Source.Location) } - return getContentTypeFromPath(sr.source.Location) + return getContentTypeFromPath(sr.Source.Location) } // Read returns a reader for the asset content and its size. func (sr *SourceReader) Read(ctx context.Context) (io.ReadCloser, int64, error) { - if isRemoteURL(sr.source.Location) { + if isRemoteURL(sr.Source.Location) { return sr.readRemote(ctx) } return sr.readLocal() diff --git a/cli/internal/deploy/source_reader_test.go b/cli/internal/deploy/source_reader_test.go index ad2662959..a50f7b982 100644 --- a/cli/internal/deploy/source_reader_test.go +++ b/cli/internal/deploy/source_reader_test.go @@ -37,7 +37,7 @@ info: reader := NewSourceReader(source) // Test type method - require.Equal(t, "openapiv3", string(reader.source.Type)) + require.Equal(t, "openapiv3", string(reader.Source.Type)) // Test content type method require.Equal(t, "application/yaml", reader.GetContentType()) diff --git a/cli/internal/deploy/upload.go b/cli/internal/deploy/upload.go index c64e12973..a869d78b6 100644 --- a/cli/internal/deploy/upload.go +++ b/cli/internal/deploy/upload.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "log/slog" "github.com/speakeasy-api/gram/cli/internal/api" "github.com/speakeasy-api/gram/cli/internal/secret" @@ -12,47 +11,42 @@ import ( "github.com/speakeasy-api/gram/server/gen/types" ) -func isSupportedSourceType(source Source) bool { - return source.Type == SourceTypeOpenAPIV3 +type UploadRequest struct { + APIKey secret.Secret + ProjectSlug string + SourceReader *SourceReader } -type uploadRequest struct { - apiKey secret.Secret - projectSlug string - sourceReader *SourceReader -} - -func (ur *uploadRequest) Read(ctx context.Context) (io.ReadCloser, int64, error) { - reader, size, err := ur.sourceReader.Read(ctx) +func (ur *UploadRequest) Read(ctx context.Context) (io.ReadCloser, int64, error) { + reader, size, err := ur.SourceReader.Read(ctx) if err != nil { return nil, 0, fmt.Errorf("failed to read source: %w", err) } return reader, size, nil } -func uploadFromSource( +func Upload( ctx context.Context, - logger *slog.Logger, assetsClient *api.AssetsClient, - req *uploadRequest, + req *UploadRequest, ) (*deployments.AddOpenAPIv3DeploymentAssetForm, error) { - rc, length, err := req.sourceReader.Read(ctx) + rc, length, err := req.SourceReader.Read(ctx) if err != nil { return nil, fmt.Errorf("failed to read source: %w", err) } - source := req.sourceReader.source + source := req.SourceReader.Source - uploadRes, err := assetsClient.UploadOpenAPIv3(ctx, logger, &api.UploadOpenAPIv3Request{ - APIKey: req.apiKey.Reveal(), - ProjectSlug: req.projectSlug, + uploadRes, err := assetsClient.UploadOpenAPIv3(ctx, &api.UploadOpenAPIv3Request{ + APIKey: req.APIKey.Reveal(), + ProjectSlug: req.ProjectSlug, Reader: rc, - ContentType: req.sourceReader.GetContentType(), + ContentType: req.SourceReader.GetContentType(), ContentLength: length, }) if err != nil { msg := "failed to upload asset in project '%s' for source %s: %w" - return nil, fmt.Errorf(msg, req.projectSlug, source.Location, err) + return nil, fmt.Errorf(msg, req.ProjectSlug, source.Location, err) } return &deployments.AddOpenAPIv3DeploymentAssetForm{ @@ -62,40 +56,3 @@ func uploadFromSource( }, nil } - -// createAssetsForDeployment creates remote assets out of each incoming source. -// The returned forms can be submitted to create a deployment. -func createAssetsForDeployment( - ctx context.Context, - logger *slog.Logger, - assetsClient *api.AssetsClient, - req *CreateDeploymentRequest, -) ([]*deployments.AddOpenAPIv3DeploymentAssetForm, error) { - sources := req.Config.Sources - project := req.ProjectSlug - assets := make([]*deployments.AddOpenAPIv3DeploymentAssetForm, 0, len(sources)) - - for _, source := range sources { - if !isSupportedSourceType(source) { - msg := "skipping unsupported source type" - logger.WarnContext(ctx, msg, slog.String("type", string(source.Type)), slog.String("location", source.Location)) - continue - } - - asset, err := uploadFromSource(ctx, logger, assetsClient, &uploadRequest{ - apiKey: req.APIKey, - projectSlug: project, - sourceReader: NewSourceReader(source), - }) - if err != nil { - return nil, err - } - assets = append(assets, asset) - } - - if len(assets) == 0 { - return nil, fmt.Errorf("no valid sources found") - } - - return assets, nil -} diff --git a/cli/internal/deploy/workflow.go b/cli/internal/deploy/workflow.go new file mode 100644 index 000000000..c80e2cdae --- /dev/null +++ b/cli/internal/deploy/workflow.go @@ -0,0 +1,310 @@ +package deploy + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "time" + + "github.com/speakeasy-api/gram/cli/internal/api" + "github.com/speakeasy-api/gram/cli/internal/secret" + "github.com/speakeasy-api/gram/server/gen/deployments" + "github.com/speakeasy-api/gram/server/gen/types" +) + +type WorkflowParams struct { + APIKey secret.Secret + APIURL *url.URL + ProjectSlug string +} + +func (p WorkflowParams) Validate() error { + if p.ProjectSlug == "" { + return fmt.Errorf("project slug is required") + } + if p.APIKey.Reveal() == "" { + return fmt.Errorf("API key is required") + } + if p.APIURL == nil { + return fmt.Errorf("API URL is required") + } + return nil +} + +type Workflow struct { + Logger *slog.Logger + Params WorkflowParams + AssetsClient *api.AssetsClient + DeploymentsClient *api.DeploymentsClient + NewAssets []*deployments.AddOpenAPIv3DeploymentAssetForm + Deployment *types.Deployment + Err error +} + +// Fail indicates an unexpected error and halts execution. +func (s *Workflow) Fail(err error) *Workflow { + s.Err = err + return s +} + +// Failed indicates an unexpected error has interrupted the workflow. +func (s *Workflow) Failed() bool { + return s.Err != nil +} + +func NewWorkflow( + ctx context.Context, + logger *slog.Logger, + params WorkflowParams, +) *Workflow { + state := &Workflow{ + Logger: logger, + Params: params, + AssetsClient: nil, + DeploymentsClient: nil, + NewAssets: nil, + Deployment: nil, + Err: nil, + } + + if err := params.Validate(); err != nil { + return state.Fail(err) + } + + state.AssetsClient = api.NewAssetsClient(&api.AssetsClientOptions{ + Scheme: params.APIURL.Scheme, + Host: params.APIURL.Host, + }) + state.DeploymentsClient = api.NewDeploymentsClient( + &api.DeploymentsClientOptions{ + Scheme: params.APIURL.Scheme, + Host: params.APIURL.Host, + }, + ) + + return state +} + +func (s *Workflow) UploadAssets( + ctx context.Context, + sources []Source, +) *Workflow { + if s.Failed() { + return s + } + + s.Logger.InfoContext(ctx, "uploading files") + newAssets := make( + []*deployments.AddOpenAPIv3DeploymentAssetForm, + len(sources), + ) + + for idx, source := range sources { + if err := source.Validate(); err != nil { + return s.Fail(fmt.Errorf("invalid source: %w", err)) + } + + upReq := &UploadRequest{ + APIKey: s.Params.APIKey, + ProjectSlug: s.Params.ProjectSlug, + SourceReader: NewSourceReader(source), + } + asset, err := Upload(ctx, s.AssetsClient, upReq) + if err != nil { + return s.Fail(fmt.Errorf("failed to upload asset: %w", err)) + } + + newAssets[idx] = asset + } + s.NewAssets = newAssets + return s +} + +func (s *Workflow) EvolveDeployment( + ctx context.Context, +) *Workflow { + if s.Failed() { + return s + } + + if s.Deployment == nil { + return s.Fail(fmt.Errorf("update failed: no deployment found")) + } + s.Logger.InfoContext( + ctx, + "updating deployment", + slog.String("deployment_id", s.Deployment.ID), + ) + evolved, err := s.DeploymentsClient.Evolve(ctx, api.EvolveRequest{ + Assets: s.NewAssets, + APIKey: s.Params.APIKey, + DeploymentID: s.Deployment.ID, + ProjectSlug: s.Params.ProjectSlug, + }) + if err != nil { + return s.Fail(fmt.Errorf("failed to evolve deployment: %w", err)) + } + + s.Logger.InfoContext( + ctx, + "updated deployment", + slog.String("deployment_id", evolved.Deployment.ID), + ) + + s.Deployment = evolved.Deployment + + return s +} + +func (s *Workflow) CreateDeployment( + ctx context.Context, + idem string, +) *Workflow { + if s.Failed() { + return s + } + + s.Logger.InfoContext(ctx, "creating deployment") + createReq := api.CreateDeploymentRequest{ + APIKey: s.Params.APIKey, + IdempotencyKey: idem, + OpenAPIv3Assets: s.NewAssets, + ProjectSlug: s.Params.ProjectSlug, + } + result, err := s.DeploymentsClient.CreateDeployment(ctx, createReq) + if err != nil { + return s.Fail(fmt.Errorf("failed to create deployment: %w", err)) + } + + s.Logger.InfoContext( + ctx, + "created new deployment", + slog.String("deployment_id", result.Deployment.ID), + ) + + s.Deployment = result.Deployment + + return s +} + +func (s *Workflow) LoadDeploymentByID( + ctx context.Context, + deploymentID string, +) *Workflow { + if s.Failed() { + return s + } + + result, err := s.DeploymentsClient.GetDeployment( + ctx, + s.Params.APIKey, + s.Params.ProjectSlug, + deploymentID, + ) + if err != nil { + return s.Fail( + fmt.Errorf("failed to get deployment '%s': %w", deploymentID, err), + ) + } + + s.Deployment = result + return s +} + +func (s *Workflow) LoadLatestDeployment( + ctx context.Context, +) *Workflow { + if s.Failed() { + return s + } + + result, err := s.DeploymentsClient.GetLatestDeployment( + ctx, + s.Params.APIKey, + s.Params.ProjectSlug, + ) + if err != nil { + return s.Fail(fmt.Errorf("failed to get latest deployment: %w", err)) + } + + s.Deployment = result + return s +} + +func (s *Workflow) LoadActiveDeployment( + ctx context.Context, +) *Workflow { + if s.Failed() { + return s + } + + result, err := s.DeploymentsClient.GetActiveDeployment( + ctx, + s.Params.APIKey, + s.Params.ProjectSlug, + ) + if err != nil { + return s.Fail(fmt.Errorf("failed to get active deployment: %w", err)) + } + + s.Deployment = result + return s +} + +func (s *Workflow) Poll(ctx context.Context) *Workflow { + if s.Failed() { + return s + } + + if s.Deployment == nil { + return s.Fail(fmt.Errorf("poll failed: no deployment found")) + } + + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + s.Logger.InfoContext( + ctx, + "Polling deployment status...", + slog.String("deployment_id", s.Deployment.ID), + ) + + for { + select { + case <-ctx.Done(): + if ctx.Err() == context.DeadlineExceeded { + return s.Fail( + fmt.Errorf("deployment polling timed out after 2 minutes"), + ) + } + return s.Fail( + fmt.Errorf("deployment polling cancelled: %w", ctx.Err()), + ) + + case <-ticker.C: + s.LoadDeploymentByID(ctx, s.Deployment.ID) + s.Logger.DebugContext(ctx, "Deployment status check", + slog.String("deployment_id", s.Deployment.ID), + slog.String("status", s.Deployment.Status), + ) + + switch s.Deployment.Status { + case "completed", "failed": + return s + case "pending": + continue + default: + s.Logger.WarnContext( + ctx, + "Unknown deployment status", + slog.String("status", s.Deployment.Status), + ) + continue + } + } + } +} diff --git a/cli/internal/flags/flags.go b/cli/internal/flags/flags.go new file mode 100644 index 000000000..91286ff63 --- /dev/null +++ b/cli/internal/flags/flags.go @@ -0,0 +1,32 @@ +// Package flags defines common flags to keep CLI commands consistent. +package flags + +import "github.com/urfave/cli/v2" + +func APIKey() *cli.StringFlag { + return &cli.StringFlag{ + Name: "api-key", + Usage: "Your Gram API key (must be scoped as a 'Provider')", + EnvVars: []string{"GRAM_API_KEY"}, + Required: true, + } +} + +func APIEndpoint() *cli.StringFlag { + return &cli.StringFlag{ + Name: "api-url", + Usage: "The base URL to use for API calls.", + EnvVars: []string{"GRAM_API_URL"}, + Value: "https://app.getgram.ai", + Hidden: true, + } +} + +func Project() *cli.StringFlag { + return &cli.StringFlag{ + Name: "project", + Usage: "The target Gram project", + EnvVars: []string{"GRAM_PROJECT"}, + Required: true, + } +}