From 054906b882461739a26190ca7c7dc61c47c630eb Mon Sep 17 00:00:00 2001 From: Laurence Date: Tue, 21 Apr 2026 12:21:44 +0100 Subject: [PATCH 1/3] enhance(blueprint): support integration api usage --- cmd/apply/blueprint/blueprint.go | 78 +++++++++++------ docs/pangolin_apply_blueprint.md | 3 + internal/api/client.go | 146 +++++++++++++++++-------------- internal/api/global.go | 1 + internal/api/session.go | 99 +++++++++++++++++++++ internal/api/types.go | 11 +-- 6 files changed, 237 insertions(+), 101 deletions(-) create mode 100644 internal/api/session.go diff --git a/cmd/apply/blueprint/blueprint.go b/cmd/apply/blueprint/blueprint.go index b4944d0..78a91ac 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" "strings" @@ -13,8 +14,11 @@ import ( ) type BlueprintCmdOpts struct { - Name string - Path string + Name string + Path string + APIKey string + Endpoint string + OrgID string } func BlueprintCmd() *cobra.Command { @@ -29,6 +33,17 @@ func BlueprintCmd() *cobra.Command { return errors.New("--file is required") } + // API key mode requires endpoint and org. + if opts.APIKey != "" && opts.Endpoint == "" { + return errors.New("--endpoint is required when using --api-key (use your Integration API URL, e.g. https:///v1)") + } + if opts.APIKey != "" && opts.OrgID == "" { + return errors.New("--org is required when using --api-key") + } + if opts.APIKey == "" && opts.OrgID != "" { + return errors.New("--org is only supported when using --api-key") + } + if _, err := os.Stat(opts.Path); err != nil { return err } @@ -36,11 +51,10 @@ func BlueprintCmd() *cobra.Command { // 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 { + switch ext := strings.ToLower(filepath.Ext(filename)); ext { + case ".yaml", ".yml": + opts.Name = strings.TrimSuffix(filename, ext) + default: opts.Name = filename } } @@ -51,48 +65,56 @@ func BlueprintCmd() *cobra.Command { 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().StringVar(&opts.APIKey, "api-key", "", "Integration API key (.)") + cmd.Flags().StringVar(&opts.Endpoint, "endpoint", "", "Integration API host URL (required with --api-key, e.g. https://pangolin-api.example.com)") + cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (required with --api-key)") cmd.MarkFlagRequired("file") return cmd } func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error { - api := api.FromContext(cmd.Context()) + apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) - account, err := accountStore.ActiveAccount() + blueprintContents, err := os.ReadFile(opts.Path) if err != nil { - logger.Error("Error: %v", err) - return err + return fmt.Errorf("failed to read blueprint file: %w", err) } - if account.OrgID == "" { - logger.Error("Error: no organization selected. Run 'pangolin select org' first.") - return errors.New("no organization selected") + client := apiClient + orgID := opts.OrgID + + 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 } - blueprintContents, err := os.ReadFile(opts.Path) - if err != nil { - logger.Error("Error: failed to read blueprint file: %v", err) - return err - } - - _, err = api.ApplyBlueprint(account.OrgID, opts.Name, string(blueprintContents)) + _, err = client.ApplyBlueprint(orgID, opts.Name, string(blueprintContents)) if err != nil { - logger.Error("Error: failed to apply blueprint: %v", err) - return err + return fmt.Errorf("failed to apply blueprint: %w", err) } - - logger.Info("Successfully applied blueprint!") - return nil } diff --git a/docs/pangolin_apply_blueprint.md b/docs/pangolin_apply_blueprint.md index 02a64e1..94d3276 100644 --- a/docs/pangolin_apply_blueprint.md +++ b/docs/pangolin_apply_blueprint.md @@ -13,9 +13,12 @@ pangolin apply blueprint [flags] ### Options ``` + --api-key string Integration API key (.) + --endpoint string Integration API host URL (required with --api-key, e.g. https://pangolin-api.example.com) -f, --file string Path to blueprint file (required) -h, --help help for blueprint -n, --name string Name of blueprint (default: filename, without extension) + --org string Organization ID (required with --api-key) ``` ### SEE ALSO diff --git a/internal/api/client.go b/internal/api/client.go index a0f6c6e..d61fa1e 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" @@ -11,6 +12,7 @@ import ( "time" "github.com/fosrl/cli/internal/version" + yaml "go.yaml.in/yaml/v3" ) // ClientConfig holds configuration for creating a new client @@ -32,22 +34,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 @@ -110,23 +116,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 { @@ -237,7 +227,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 @@ -452,16 +465,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 { @@ -623,12 +627,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) @@ -640,7 +639,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 } @@ -715,12 +714,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) @@ -778,12 +772,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) @@ -823,20 +812,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 4eed2b6..3981b06 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 From adfc5518f78d755b2ee3f8a65f979e6fca20355c Mon Sep 17 00:00:00 2001 From: Laurence Date: Tue, 21 Apr 2026 20:17:09 +0100 Subject: [PATCH 2/3] Enforce that if intent is to use the integration api all flags must be provided --- cmd/apply/blueprint/blueprint.go | 63 ++++++++++++-------------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/cmd/apply/blueprint/blueprint.go b/cmd/apply/blueprint/blueprint.go index a0d0f56..b1d518f 100644 --- a/cmd/apply/blueprint/blueprint.go +++ b/cmd/apply/blueprint/blueprint.go @@ -30,40 +30,11 @@ 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") } - - // API key mode requires endpoint and org. - if opts.APIKey != "" && opts.Endpoint == "" { - return errors.New("--endpoint is required when using --api-key (use your Integration API URL, e.g. https:///v1)") - } - if opts.APIKey != "" && opts.OrgID == "" { - return errors.New("--org is required when using --api-key") - } - if opts.APIKey == "" && opts.OrgID != "" { - return errors.New("--org is only supported when using --api-key") - } - - 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) - switch ext := strings.ToLower(filepath.Ext(filename)); ext { - case ".yaml", ".yml": - opts.Name = strings.TrimSuffix(filename, ext) - default: - opts.Name = filename - } - } - - if len(opts.Name) < 1 || len(opts.Name) > 255 { - return errors.New("name must be between 1-255 characters") - } - return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -75,17 +46,31 @@ func BlueprintCmd() *cobra.Command { }, } - 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().StringVar(&opts.APIKey, "api-key", "", "Integration API key (.)") - cmd.Flags().StringVar(&opts.Endpoint, "endpoint", "", "Integration API host URL (required with --api-key, e.g. https://pangolin-api.example.com)") - cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (required with --api-key)") + 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 { + 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 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()) @@ -115,7 +100,7 @@ func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error { orgID = account.OrgID } - _, err = client.ApplyBlueprint(orgID, opts.Name, string(blueprintContents)) + _, err = client.ApplyBlueprint(orgID, name, string(blueprintContents)) if err != nil { return fmt.Errorf("failed to apply blueprint: %w", err) } From 7de8d5bf1fb801f50ac3794e7eae7dc423fea376 Mon Sep 17 00:00:00 2001 From: Laurence Date: Tue, 21 Apr 2026 20:20:45 +0100 Subject: [PATCH 3/3] revert generated docs --- docs/pangolin_apply_blueprint.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/pangolin_apply_blueprint.md b/docs/pangolin_apply_blueprint.md index 94d3276..02a64e1 100644 --- a/docs/pangolin_apply_blueprint.md +++ b/docs/pangolin_apply_blueprint.md @@ -13,12 +13,9 @@ pangolin apply blueprint [flags] ### Options ``` - --api-key string Integration API key (.) - --endpoint string Integration API host URL (required with --api-key, e.g. https://pangolin-api.example.com) -f, --file string Path to blueprint file (required) -h, --help help for blueprint -n, --name string Name of blueprint (default: filename, without extension) - --org string Organization ID (required with --api-key) ``` ### SEE ALSO