diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go new file mode 100644 index 00000000..b37e9801 --- /dev/null +++ b/cmd/account/access/access.go @@ -0,0 +1,63 @@ +package access + +import ( + "fmt" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/accessrequest" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +func New(runtimeCtx *runtime.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "access", + Short: "Check or request deployment access", + Long: "Check your deployment access status or request access to deploy workflows.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + h := NewHandler(runtimeCtx, cmd.InOrStdin()) + return h.Execute() + }, + } + + return cmd +} + +type Handler struct { + log *zerolog.Logger + credentials *credentials.Credentials + requester *accessrequest.Requester +} + +func NewHandler(ctx *runtime.Context, stdin interface{ Read([]byte) (int, error) }) *Handler { + return &Handler{ + log: ctx.Logger, + credentials: ctx.Credentials, + requester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger, stdin), + } +} + +func (h *Handler) Execute() error { + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + return fmt.Errorf("failed to check deployment access: %w", err) + } + + if deployAccess.HasAccess { + fmt.Println("") + fmt.Println("You have deployment access enabled for your organization.") + fmt.Println("") + fmt.Println("You're all set to deploy workflows. Get started with:") + fmt.Println("") + fmt.Println(" cre workflow deploy") + fmt.Println("") + fmt.Println("For more information, run 'cre workflow deploy --help'") + fmt.Println("") + return nil + } + + return h.requester.PromptAndSubmitRequest() +} diff --git a/cmd/account/account.go b/cmd/account/account.go index d69ec3a9..bc96644c 100644 --- a/cmd/account/account.go +++ b/cmd/account/account.go @@ -3,6 +3,7 @@ package account import ( "github.com/spf13/cobra" + "github.com/smartcontractkit/cre-cli/cmd/account/access" "github.com/smartcontractkit/cre-cli/cmd/account/link_key" "github.com/smartcontractkit/cre-cli/cmd/account/list_key" "github.com/smartcontractkit/cre-cli/cmd/account/unlink_key" @@ -12,10 +13,11 @@ import ( func New(runtimeContext *runtime.Context) *cobra.Command { accountCmd := &cobra.Command{ Use: "account", - Short: "Manages account", - Long: "Manage your linked public key addresses for workflow operations.", + Short: "Manage account and request deploy access", + Long: "Manage your linked public key addresses for workflow operations and request deployment access.", } + accountCmd.AddCommand(access.New(runtimeContext)) accountCmd.AddCommand(link_key.New(runtimeContext)) accountCmd.AddCommand(unlink_key.New(runtimeContext)) accountCmd.AddCommand(list_key.New(runtimeContext)) diff --git a/cmd/root.go b/cmd/root.go index 51af5fb8..7c21d264 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/context" + "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/logger" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" @@ -126,7 +127,7 @@ func newRootCommand() *cobra.Command { // Check if organization is ungated for commands that require it cmdPath := cmd.CommandPath() - if cmdPath == "cre account link-key" || cmdPath == "cre workflow deploy" { + if cmdPath == "cre account link-key" { if err := runtimeContext.Credentials.CheckIsUngatedOrganization(); err != nil { return err } @@ -174,6 +175,22 @@ func newRootCommand() *cobra.Command { return false }) + cobra.AddTemplateFunc("needsDeployAccess", func() bool { + creds := runtimeContext.Credentials + if creds == nil { + var err error + creds, err = credentials.New(rootLogger) + if err != nil { + return false + } + } + deployAccess, err := creds.GetDeploymentAccessStatus() + if err != nil { + return false + } + return !deployAccess.HasAccess + }) + rootCmd.SetHelpTemplate(helpTemplate) // Definition of global flags: @@ -261,6 +278,7 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre login": {}, "cre logout": {}, "cre whoami": {}, + "cre account access": {}, "cre account list-key": {}, "cre init": {}, "cre generate-bindings": {}, diff --git a/cmd/template/help_template.tpl b/cmd/template/help_template.tpl index 2e55d2d3..cef15808 100644 --- a/cmd/template/help_template.tpl +++ b/cmd/template/help_template.tpl @@ -91,6 +91,12 @@ Use "{{.CommandPath}} [command] --help" for more information about a command. to login into your cre account, then: $ cre init to create your first cre project. +{{- if needsDeployAccess}} + +šŸ”‘ Ready to deploy? Run: + $ cre account access + to request deployment access. +{{- end}} šŸ“˜ Need more help? Visit https://docs.chain.link/cre diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go index 6719baf8..b26ebb2f 100644 --- a/cmd/whoami/whoami.go +++ b/cmd/whoami/whoami.go @@ -82,6 +82,12 @@ func (h *Handler) Execute(ctx context.Context) error { return fmt.Errorf("graphql request failed: %w", err) } + // Get deployment access status + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + h.log.Debug().Err(err).Msg("failed to get deployment access status") + } + fmt.Println("") fmt.Println("Account details retrieved:") fmt.Println("") @@ -90,6 +96,16 @@ func (h *Handler) Execute(ctx context.Context) error { } fmt.Printf("\tOrganization ID: %s\n", respEnvelope.GetOrganization.OrganizationID) fmt.Printf("\tOrganization Name: %s\n", respEnvelope.GetOrganization.DisplayName) + + // Display deployment access status + if deployAccess != nil { + if deployAccess.HasAccess { + fmt.Printf("\tDeploy Access: Enabled\n") + } else { + fmt.Printf("\tDeploy Access: Not enabled (run 'cre account access' to request)\n") + } + } + fmt.Println("") return nil diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 2839ae68..8f0015e8 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -1,6 +1,7 @@ package deploy import ( + "context" "errors" "fmt" "io" @@ -13,6 +14,7 @@ import ( "github.com/spf13/viper" "github.com/smartcontractkit/cre-cli/cmd/client" + "github.com/smartcontractkit/cre-cli/internal/accessrequest" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" @@ -62,6 +64,7 @@ type handler struct { workflowArtifact *workflowArtifact wrc *client.WorkflowRegistryV2Client runtimeContext *runtime.Context + accessRequester *accessrequest.Requester validated bool @@ -94,7 +97,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { if err := h.ValidateInputs(); err != nil { return err } - return h.Execute() + return h.Execute(cmd.Context()) }, } @@ -118,10 +121,16 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { workflowArtifact: &workflowArtifact{}, wrc: nil, runtimeContext: ctx, + accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger, stdin), validated: false, wg: sync.WaitGroup{}, wrcErr: nil, } + + return &h +} + +func (h *handler) initWorkflowRegistryClient() { h.wg.Add(1) go func() { defer h.wg.Done() @@ -132,8 +141,6 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { } h.wrc = wrc }() - - return &h } func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) { @@ -177,7 +184,18 @@ func (h *handler) ValidateInputs() error { return nil } -func (h *handler) Execute() error { +func (h *handler) Execute(ctx context.Context) error { + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + return fmt.Errorf("failed to check deployment access: %w", err) + } + + if !deployAccess.HasAccess { + return h.accessRequester.PromptAndSubmitRequest() + } + + h.initWorkflowRegistryClient() + h.displayWorkflowDetails() if err := h.Compile(); err != nil { diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index bd62e471..10b08853 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -41,6 +41,7 @@ import ( cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/validation" @@ -101,6 +102,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { type handler struct { log *zerolog.Logger runtimeContext *runtime.Context + credentials *credentials.Credentials validated bool } @@ -108,6 +110,7 @@ func newHandler(ctx *runtime.Context) *handler { return &handler{ log: ctx.Logger, runtimeContext: ctx, + credentials: ctx.Credentials, validated: false, } } @@ -332,7 +335,36 @@ func (h *handler) Execute(inputs Inputs) error { // if logger instance is set to DEBUG, that means verbosity flag is set by the user verbosity := h.log.GetLevel() == zerolog.DebugLevel - return run(ctx, wasmFileBinary, config, secrets, inputs, verbosity) + err = run(ctx, wasmFileBinary, config, secrets, inputs, verbosity) + if err != nil { + return err + } + + h.showDeployAccessHint() + + return nil +} + +func (h *handler) showDeployAccessHint() { + if h.credentials == nil { + return + } + + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + return + } + + if !deployAccess.HasAccess { + fmt.Println("") + fmt.Println("─────────────────────────────────────────────────────────────") + fmt.Println("") + fmt.Println(" Simulation complete! Ready to deploy your workflow?") + fmt.Println("") + fmt.Println(" Run 'cre account access' to request deployment access.") + fmt.Println("") + fmt.Println("─────────────────────────────────────────────────────────────") + } } // run instantiates the engine, starts it and blocks until the context is canceled. diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go new file mode 100644 index 00000000..5c87dc2c --- /dev/null +++ b/internal/accessrequest/accessrequest.go @@ -0,0 +1,139 @@ +package accessrequest + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/rs/zerolog" + + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/prompt" +) + +const ( + EnvVarAccessRequestURL = "CRE_ACCESS_REQUEST_URL" +) + +type AccessRequest struct { + UseCase string `json:"useCase"` +} + +type Requester struct { + credentials *credentials.Credentials + log *zerolog.Logger + stdin io.Reader +} + +func NewRequester(creds *credentials.Credentials, log *zerolog.Logger, stdin io.Reader) *Requester { + return &Requester{ + credentials: creds, + log: log, + stdin: stdin, + } +} + +func (r *Requester) PromptAndSubmitRequest() error { + fmt.Println("") + fmt.Println("Deployment access is not yet enabled for your organization.") + fmt.Println("") + + shouldRequest, err := prompt.YesNoPrompt(r.stdin, "Request deployment access?") + if err != nil { + return fmt.Errorf("failed to get user confirmation: %w", err) + } + + if !shouldRequest { + fmt.Println("") + fmt.Println("Access request canceled.") + return nil + } + + fmt.Println("") + fmt.Println("Briefly describe your use case (what are you building with CRE?):") + fmt.Print("> ") + + reader := bufio.NewReader(r.stdin) + useCase, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read use case: %w", err) + } + useCase = strings.TrimSpace(useCase) + + if useCase == "" { + return fmt.Errorf("use case description is required") + } + + fmt.Println("") + fmt.Println("Submitting access request...") + + if err := r.SubmitAccessRequest(useCase); err != nil { + return fmt.Errorf("failed to submit access request: %w", err) + } + + fmt.Println("") + fmt.Println("Access request submitted successfully!") + fmt.Println("") + fmt.Println("Our team will review your request and get back to you shortly.") + fmt.Println("") + + return nil +} + +func (r *Requester) SubmitAccessRequest(useCase string) error { + apiURL := os.Getenv(EnvVarAccessRequestURL) + if apiURL == "" { + return fmt.Errorf("access request API URL not configured (set %s environment variable)", EnvVarAccessRequestURL) + } + + reqBody := AccessRequest{ + UseCase: useCase, + } + + jsonBody, err := json.MarshalIndent(reqBody, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + if r.credentials.Tokens == nil || r.credentials.Tokens.AccessToken == "" { + return fmt.Errorf("no access token available - please run 'cre login' first") + } + token := r.credentials.Tokens.AccessToken + + fmt.Println("") + fmt.Println("Request Details:") + fmt.Println("----------------") + fmt.Printf("URL: %s\n", apiURL) + fmt.Printf("Method: POST\n") + fmt.Println("Headers:") + fmt.Println(" Content-Type: application/json") + fmt.Printf(" Authorization: Bearer %s\n", token) + fmt.Println("Body:") + fmt.Println(string(jsonBody)) + fmt.Println("----------------") + + req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(jsonBody)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("access request API returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 6b53867b..1dd6dc59 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -35,10 +35,19 @@ const ( AuthTypeBearer = "bearer" ConfigDir = ".cre" ConfigFile = "cre.yaml" + + // DeploymentAccessStatusFullAccess indicates the organization has full deployment access + DeploymentAccessStatusFullAccess = "FULL_ACCESS" ) +// DeploymentAccess holds information about an organization's deployment access status +type DeploymentAccess struct { + HasAccess bool // Whether the organization has deployment access + Status string // The raw status value (e.g., "FULL_ACCESS", "PENDING", etc.) +} + // UngatedOrgRequiredMsg is the error message shown when an organization does not have ungated access. -var UngatedOrgRequiredMsg = "\nāœ– Workflow deployment is currently in early access. We're onboarding organizations gradually.\n\nWant to deploy?\n→ Request access here: https://cre.chain.link/request-access\n" +var UngatedOrgRequiredMsg = "\nāœ– Workflow deployment is currently in early access. We're onboarding organizations gradually.\n\nWant to deploy?\n→ Run 'cre account access' to request access\n" func New(logger *zerolog.Logger) (*Credentials, error) { cfg := &Credentials{ @@ -96,36 +105,38 @@ func SaveCredentials(tokenSet *CreLoginTokenSet) error { return nil } -// CheckIsUngatedOrganization verifies that the organization associated with the credentials -// has FULL_ACCESS status (is not gated). This check is required for certain operations like -// workflow key linking. -func (c *Credentials) CheckIsUngatedOrganization() error { - // API keys can only be generated on ungated organizations, so they always pass +// GetDeploymentAccessStatus returns the deployment access status for the organization. +// This can be used to check and display whether the user has deployment access. +func (c *Credentials) GetDeploymentAccessStatus() (*DeploymentAccess, error) { + // API keys can only be generated on ungated organizations, so they always have access if c.AuthType == AuthTypeApiKey { - return nil + return &DeploymentAccess{ + HasAccess: true, + Status: DeploymentAccessStatusFullAccess, + }, nil } // For JWT bearer tokens, we need to parse the token and check the organization_status claim if c.Tokens == nil || c.Tokens.AccessToken == "" { - return fmt.Errorf("no access token available") + return nil, fmt.Errorf("no access token available") } // Parse the JWT to extract claims parts := strings.Split(c.Tokens.AccessToken, ".") if len(parts) < 2 { - return fmt.Errorf("invalid JWT token format") + return nil, fmt.Errorf("invalid JWT token format") } // Decode the payload (second part of the JWT) payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { - return fmt.Errorf("failed to decode JWT payload: %w", err) + return nil, fmt.Errorf("failed to decode JWT payload: %w", err) } // Parse claims into a map var claims map[string]interface{} if err := json.Unmarshal(payload, &claims); err != nil { - return fmt.Errorf("failed to unmarshal JWT claims: %w", err) + return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err) } // Log all claims for debugging @@ -146,17 +157,27 @@ func (c *Credentials) CheckIsUngatedOrganization() error { c.log.Debug().Str("claim_key", orgStatusKey).Str("organization_status", orgStatus).Msg("checking organization status claim") - if orgStatus == "" { - // If the claim is missing or empty, the organization is considered gated - return errors.New(UngatedOrgRequiredMsg) + hasAccess := orgStatus == DeploymentAccessStatusFullAccess + c.log.Debug().Str("organization_status", orgStatus).Bool("has_access", hasAccess).Msg("deployment access status retrieved") + + return &DeploymentAccess{ + HasAccess: hasAccess, + Status: orgStatus, + }, nil +} + +// CheckIsUngatedOrganization verifies that the organization associated with the credentials +// has FULL_ACCESS status (is not gated). This check is required for certain operations like +// workflow key linking. +func (c *Credentials) CheckIsUngatedOrganization() error { + access, err := c.GetDeploymentAccessStatus() + if err != nil { + return err } - // Check if the organization has full access - if orgStatus != "FULL_ACCESS" { - c.log.Debug().Str("organization_status", orgStatus).Msg("organization does not have FULL_ACCESS - organization is gated") + if !access.HasAccess { return errors.New(UngatedOrgRequiredMsg) } - c.log.Debug().Str("organization_status", orgStatus).Msg("organization has FULL_ACCESS - organization is ungated") return nil }