Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 55 additions & 48 deletions cmd/apply/blueprint/blueprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package blueprint

import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down
146 changes: 80 additions & 66 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

1 change: 1 addition & 0 deletions internal/api/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ func InitClient(hostname string, token string) (*Client, error) {

return client, nil
}

Loading
Loading