diff --git a/cmd/apply/blueprint/blueprint.go b/cmd/apply/blueprint/blueprint.go index 209b978..b1d518f 100644 --- a/cmd/apply/blueprint/blueprint.go +++ b/cmd/apply/blueprint/blueprint.go @@ -2,6 +2,7 @@ package blueprint import ( "errors" + "fmt" "os" "path/filepath" "regexp" @@ -14,8 +15,11 @@ import ( ) type BlueprintCmdOpts struct { - Name string - Path string + Name string + Path string + APIKey string + Endpoint string + OrgID string } func BlueprintCmd() *cobra.Command { @@ -26,77 +30,80 @@ func BlueprintCmd() *cobra.Command { Short: "Apply a blueprint", Long: "Apply a YAML blueprint to the Pangolin server", PreRunE: func(cmd *cobra.Command, args []string) error { - if opts.Path == "" { - return errors.New("--file is required") + // Integration API: any of the three flags implies all three are required (avoids silent session fallback). + integration := opts.APIKey != "" || opts.Endpoint != "" || opts.OrgID != "" + if integration && (opts.APIKey == "" || opts.Endpoint == "" || opts.OrgID == "") { + return errors.New("integration API mode requires --api-key, --endpoint, and --org together; omit all three to use your logged-in session and selected org") } - - if _, err := os.Stat(opts.Path); err != nil { - return err - } - - // Strip file extension and use file basename path as name - if opts.Name == "" { - filename := filepath.Base(opts.Path) - if before, ok := strings.CutSuffix(filename, ".yaml"); ok { - opts.Name = before - } else if before, ok := strings.CutSuffix(filename, ".yml"); ok { - opts.Name = before - } else { - opts.Name = filename - } - } - - if len(opts.Name) < 1 || len(opts.Name) > 255 { - return errors.New("name must be between 1-255 characters") - } - return nil }, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if err := applyBlueprintMain(cmd, opts); err != nil { - os.Exit(1) + return err } + logger.Info("Successfully applied blueprint!") + return nil }, } - cmd.Flags().StringVarP(&opts.Path, "file", "f", "", "Path to blueprint file (required)") - cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Name of blueprint (default: filename, without extension)") + cmd.Flags().StringVarP(&opts.Path, "file", "f", "", "Blueprint YAML file") + cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Blueprint name (default: filename without extension)") + cmd.Flags().StringVar(&opts.APIKey, "api-key", "", "Integration API key (id.secret)") + cmd.Flags().StringVar(&opts.Endpoint, "endpoint", "", "Integration API host URL") + cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID") cmd.MarkFlagRequired("file") return cmd } func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error { - api := api.FromContext(cmd.Context()) - accountStore := config.AccountStoreFromContext(cmd.Context()) - - account, err := accountStore.ActiveAccount() - if err != nil { - logger.Error("Error: %v", err) - return err + name := opts.Name + if name == "" { + filename := filepath.Base(opts.Path) + switch ext := strings.ToLower(filepath.Ext(filename)); ext { + case ".yaml", ".yml": + name = strings.TrimSuffix(filename, ext) + default: + name = filename + } } - - if account.OrgID == "" { - logger.Error("Error: no organization selected. Run 'pangolin select org' first.") - return errors.New("no organization selected") + if len(name) < 1 || len(name) > 255 { + return errors.New("name must be between 1-255 characters") } + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + blueprintContents, err := os.ReadFile(opts.Path) if err != nil { - logger.Error("Error: failed to read blueprint file: %v", err) - return err + return fmt.Errorf("failed to read blueprint file: %w", err) } blueprintContents = interpolateBlueprint(blueprintContents) - _, err = api.ApplyBlueprint(account.OrgID, opts.Name, string(blueprintContents)) - if err != nil { - logger.Error("Error: failed to apply blueprint: %v", err) - return err - } + client := apiClient + orgID := opts.OrgID - logger.Info("Successfully applied blueprint!") + if opts.APIKey != "" { + client, err = apiClient.WithIntegrationAPIKey(opts.Endpoint, opts.APIKey) + if err != nil { + return fmt.Errorf("failed to initialize api key client: %w", err) + } + } else { + account, errAcc := accountStore.ActiveAccount() + if errAcc != nil { + return errAcc + } + if account.OrgID == "" { + return errors.New("no organization selected") + } + orgID = account.OrgID + } + _, err = client.ApplyBlueprint(orgID, name, string(blueprintContents)) + if err != nil { + return fmt.Errorf("failed to apply blueprint: %w", err) + } return nil } diff --git a/internal/api/client.go b/internal/api/client.go index 0fef840..6ee4f8f 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "encoding/base64" "encoding/json" "fmt" "io" @@ -12,6 +13,7 @@ import ( "time" "github.com/fosrl/cli/internal/version" + yaml "go.yaml.in/yaml/v3" ) // ClientConfig holds configuration for creating a new client @@ -33,22 +35,26 @@ func NewClient(config ClientConfig) (*Client, error) { baseURL = "https://" + baseURL } - // Default session cookie name - sessionCookieName := config.SessionCookieName - if sessionCookieName == "" { - sessionCookieName = "p_session_token" + var session ClientSession + if config.APIKey != "" { + session = NewIntegrationAPIKeySession() + session.APIKey = config.APIKey + } else { + session = NewUserClientSession() + session.SessionToken = config.Token + } + if config.SessionCookieName != "" { + session.SessionCookieName = config.SessionCookieName + } + if config.CSRFToken != "" { + session.CSRFToken = config.CSRFToken } client := &Client{ - BaseURL: strings.TrimSuffix(baseURL, "/"), - AgentName: config.AgentName, - APIKey: config.APIKey, - Token: config.Token, - SessionCookieName: sessionCookieName, - CSRFToken: config.CSRFToken, - HTTPClient: &HTTPClient{ - Timeout: 30 * time.Second, - }, + BaseURL: strings.TrimSuffix(baseURL, "/"), + AgentName: config.AgentName, + Session: session, + HTTPClient: &HTTPClient{Timeout: 30 * time.Second}, } return client, nil @@ -111,23 +117,7 @@ func (c *Client) request(method, endpoint string, payload interface{}, result in userAgent := getUserAgent(c.AgentName) req.Header.Set("User-Agent", userAgent) - // Set CSRF header if provided - if c.CSRFToken != "" { - req.Header.Set("X-CSRF-Token", c.CSRFToken) - } - - // Set authentication - if c.Token != "" { - // Token is sent as a cookie - cookie := &http.Cookie{ - Name: c.SessionCookieName, - Value: c.Token, - } - req.AddCookie(cookie) - } else if c.APIKey != "" { - // API key is sent as Bearer token - req.Header.Set("Authorization", "Bearer "+c.APIKey) - } + c.Session.ApplyToRequest(req) // Apply custom headers from options if len(opts) > 0 && opts[0].Headers != nil { @@ -238,7 +228,30 @@ func (c *Client) SetBaseURL(baseURL string) { // SetToken updates the token for the client func (c *Client) SetToken(token string) { - c.Token = token + c.Session = NewUserClientSession() + c.Session.SessionToken = token +} + +// WithIntegrationAPIKey clones the current client and switches it to use +// integration API key authentication against the provided endpoint. +func (c *Client) WithIntegrationAPIKey(hostname, apiKey string) (*Client, error) { + baseURL := hostname + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "https://" + baseURL + } + baseURL = strings.TrimSuffix(baseURL, "/") + "/v1" + + sess := NewIntegrationAPIKeySession() + sess.APIKey = apiKey + sess.SessionCookieName = c.Session.SessionCookieName + sess.CSRFToken = c.Session.CSRFToken + + return &Client{ + BaseURL: baseURL, + AgentName: c.AgentName, + Session: sess, + HTTPClient: c.HTTPClient, + }, nil } // Logout logs out the current user @@ -469,16 +482,7 @@ func (c *Client) CheckHealth() (bool, error) { req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "application/json") - // Set authentication if available - if c.Token != "" { - cookie := &http.Cookie{ - Name: c.SessionCookieName, - Value: c.Token, - } - req.AddCookie(cookie) - } else if c.APIKey != "" { - req.Header.Set("Authorization", "Bearer "+c.APIKey) - } + c.Session.ApplyToRequest(req) resp, err := testClient.Do(req) if err != nil { @@ -640,12 +644,7 @@ func LoginWithCookie(client *Client, req LoginRequest) (*LoginResponse, string, userAgent := getUserAgent(client.AgentName) setJSONRequestHeaders(httpReq, userAgent) - // Set CSRF token header - csrfToken := client.CSRFToken - if csrfToken == "" { - csrfToken = "x-csrf-protection" - } - httpReq.Header.Set("X-CSRF-Token", csrfToken) + client.Session.ApplyToRequest(httpReq) // Execute request httpClient := createHTTPClient(client.HTTPClient.Timeout) @@ -657,7 +656,7 @@ func LoginWithCookie(client *Client, req LoginRequest) (*LoginResponse, string, // Extract session cookie for _, cookie := range resp.Cookies() { - if cookie.Name == client.SessionCookieName || cookie.Name == "p_session" { + if cookie.Name == client.Session.SessionCookieName || cookie.Name == "p_session" { sessionToken = cookie.Value break } @@ -732,12 +731,7 @@ func StartDeviceWebAuth(client *Client, req DeviceWebAuthStartRequest) (*DeviceW userAgent := getUserAgent(client.AgentName) setJSONRequestHeaders(httpReq, userAgent) - // Set CSRF token header - csrfToken := client.CSRFToken - if csrfToken == "" { - csrfToken = "x-csrf-protection" - } - httpReq.Header.Set("X-CSRF-Token", csrfToken) + client.Session.ApplyToRequest(httpReq) // Execute request httpClient := createHTTPClient(client.HTTPClient.Timeout) @@ -795,12 +789,7 @@ func PollDeviceWebAuth(client *Client, code string) (*DeviceWebAuthPollResponse, userAgent := getUserAgent(client.AgentName) setJSONResponseHeaders(httpReq, userAgent) - // Set CSRF token header - csrfToken := client.CSRFToken - if csrfToken == "" { - csrfToken = "x-csrf-protection" - } - httpReq.Header.Set("X-CSRF-Token", csrfToken) + client.Session.ApplyToRequest(httpReq) // Execute request httpClient := createHTTPClient(client.HTTPClient.Timeout) @@ -840,20 +829,45 @@ func PollDeviceWebAuth(client *Client, code string) (*DeviceWebAuthPollResponse, return &response, message, nil } +// ApplyBlueprint applies a blueprint for the given org. Behavior depends on [Client.Session]: +// user session clients use the app API with YAML in the body; integration API key clients use +// the integration host with a base64-encoded JSON payload. The name is only used for user +// session mode. func (c *Client) ApplyBlueprint(orgID string, name string, blueprint string) (*ApplyBlueprintResponse, error) { - // Create request payload with raw YAML content + if strings.TrimSpace(orgID) == "" { + return nil, fmt.Errorf("org id is required") + } + + path := fmt.Sprintf("/org/%s/blueprint", orgID) + + if c.Session.IsIntegrationAPIKey() { + var parsed interface{} + if err := yaml.Unmarshal([]byte(blueprint), &parsed); err != nil { + return nil, fmt.Errorf("failed to parse blueprint yaml: %w", err) + } + jsonBytes, err := json.Marshal(parsed) + if err != nil { + return nil, fmt.Errorf("failed to convert blueprint to json: %w", err) + } + requestBody := map[string]string{ + "blueprint": base64.StdEncoding.EncodeToString(jsonBytes), + } + var response EmptyResponse + if err := c.Put(path, requestBody, &response); err != nil { + return nil, err + } + return nil, nil + } + requestBody := ApplyBlueprintRequest{ Name: name, Blueprint: blueprint, Source: "CLI", } - - path := fmt.Sprintf("/org/%s/blueprint", orgID) var response ApplyBlueprintResponse - err := c.Put(path, requestBody, &response) - if err != nil { + if err := c.Put(path, requestBody, &response); err != nil { return nil, err } - return &response, nil } + diff --git a/internal/api/global.go b/internal/api/global.go index 5ad7758..3b935ac 100644 --- a/internal/api/global.go +++ b/internal/api/global.go @@ -31,3 +31,4 @@ func InitClient(hostname string, token string) (*Client, error) { return client, nil } + diff --git a/internal/api/session.go b/internal/api/session.go new file mode 100644 index 0000000..2f9585c --- /dev/null +++ b/internal/api/session.go @@ -0,0 +1,99 @@ +package api + +import ( + "net/http" +) + +// Default session cookie and CSRF values match the Pangolin web/API expectations. +const ( + defaultSessionCookieName = "p_session_token" + defaultCSRFToken = "x-csrf-protection" +) + +// ClientSessionMode distinguishes how requests are authenticated. +type ClientSessionMode string + +const ( + // ClientSessionModeUser is interactive login: session token sent as HTTP cookie. + ClientSessionModeUser ClientSessionMode = "user" + // ClientSessionModeIntegrationAPIKey is the Integration API: Bearer apiKeyId.apiKeySecret. + ClientSessionModeIntegrationAPIKey ClientSessionMode = "integration_api_key" +) + +// ClientSession holds all credentials and anti-CSRF state for outbound API calls. +// It is the single place for token, API key, cookie name, and CSRF header. +type ClientSession struct { + Mode ClientSessionMode + + // User mode: browser-style session + SessionToken string + SessionCookieName string + + // Integration mode: API key as Bearer "." + APIKey string + + // CSRF sent as X-CSRF-Token; empty means defaultCSRFToken is used. + CSRFToken string +} + +// NewUserClientSession returns defaults for interactive / session-cookie auth. +func NewUserClientSession() ClientSession { + return ClientSession{ + Mode: ClientSessionModeUser, + SessionCookieName: defaultSessionCookieName, + CSRFToken: defaultCSRFToken, + } +} + +// NewIntegrationAPIKeySession returns defaults for Integration API hosts (/v1/...). +func NewIntegrationAPIKeySession() ClientSession { + return ClientSession{ + Mode: ClientSessionModeIntegrationAPIKey, + SessionCookieName: defaultSessionCookieName, + CSRFToken: defaultCSRFToken, + } +} + +func (s ClientSession) sessionCookieNameOrDefault() string { + if s.SessionCookieName != "" { + return s.SessionCookieName + } + return defaultSessionCookieName +} + +func (s ClientSession) csrfValueOrDefault() string { + if s.CSRFToken != "" { + return s.CSRFToken + } + return defaultCSRFToken +} + +// IsIntegrationAPIKey reports whether this session uses Integration API key auth. +func (s ClientSession) IsIntegrationAPIKey() bool { + return s.Mode == ClientSessionModeIntegrationAPIKey +} + +// HasSessionToken reports whether a user session cookie should be attached. +func (s ClientSession) HasSessionToken() bool { + return s.SessionToken != "" +} + +// HasAPIKey reports whether Bearer API key auth should be used. +func (s ClientSession) HasAPIKey() bool { + return s.APIKey != "" +} + +// ApplyToRequest sets X-CSRF-Token and authentication on the request. +// Call this for any outbound request that should match the main API client behavior. +func (s ClientSession) ApplyToRequest(req *http.Request) { + req.Header.Set("X-CSRF-Token", s.csrfValueOrDefault()) + + if s.HasSessionToken() { + req.AddCookie(&http.Cookie{ + Name: s.sessionCookieNameOrDefault(), + Value: s.SessionToken, + }) + } else if s.HasAPIKey() { + req.Header.Set("Authorization", "Bearer "+s.APIKey) + } +} diff --git a/internal/api/types.go b/internal/api/types.go index 252cb92..01c21f3 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -9,13 +9,10 @@ import ( // Client represents the API client configuration type Client struct { - BaseURL string - AgentName string - APIKey string - Token string - SessionCookieName string - CSRFToken string - HTTPClient *HTTPClient + BaseURL string + AgentName string + Session ClientSession + HTTPClient *HTTPClient } // HTTPClient wraps the standard http.Client with additional configuration