Skip to content
Draft
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
19 changes: 16 additions & 3 deletions cmd/browser_import_managed_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
)

type managedAuthCapacity struct {
maximum int
used int
remaining int
unlimited bool
}
Expand All @@ -39,10 +41,17 @@ func decodeManagedAuthCapacity(raw string) (managedAuthCapacity, error) {
maxRaw, hasMax := fields["max_auth_connections"]
usedRaw, hasUsed := fields["auth_connections_used"]
if !hasMax || !hasUsed {
return managedAuthCapacity{}, fmt.Errorf("Kernel API does not expose Managed Auth capacity; deploy the organization entitlements API first")
return managedAuthCapacity{}, fmt.Errorf("Kernel API does not expose Managed Auth capacity through organization limits")
}
if string(maxRaw) == "null" {
return managedAuthCapacity{unlimited: true}, nil
var usedConnections int
if err := json.Unmarshal(usedRaw, &usedConnections); err != nil {
return managedAuthCapacity{}, fmt.Errorf("decode used auth connections: %w", err)
}
if usedConnections < 0 {
return managedAuthCapacity{}, fmt.Errorf("Kernel API returned invalid Managed Auth capacity")
}
return managedAuthCapacity{used: usedConnections, unlimited: true}, nil
}
var maxConnections, usedConnections int
if err := json.Unmarshal(maxRaw, &maxConnections); err != nil {
Expand All @@ -54,7 +63,11 @@ func decodeManagedAuthCapacity(raw string) (managedAuthCapacity, error) {
if maxConnections < 0 || usedConnections < 0 {
return managedAuthCapacity{}, fmt.Errorf("Kernel API returned invalid Managed Auth capacity")
}
return managedAuthCapacity{remaining: max(0, maxConnections-usedConnections)}, nil
return managedAuthCapacity{
maximum: maxConnections,
used: usedConnections,
remaining: max(0, maxConnections-usedConnections),
}, nil
}

type managedAuthProvisioner interface {
Expand Down
41 changes: 30 additions & 11 deletions cmd/browser_import_profile_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ func warnUnavailableBrowserData(category string, err error) {
}
}

func (c ProfilesImportLocalCmd) confirmBrowserImport(targetName string, cookies cookieImportSelection, cookieSites []localbrowser.Site, profileData localProfileDataSelection, logins pendingManagedAuth) (bool, error) {
func (c ProfilesImportLocalCmd) confirmBrowserImport(targetName string, cookies cookieImportSelection, cookieSites []localbrowser.Site, profileData localProfileDataSelection) (bool, error) {
pterm.Println()
pterm.Printf("Ready to import into profile %q\n\n", targetName)
if cookies.all {
Expand All @@ -253,13 +253,6 @@ func (c ProfilesImportLocalCmd) confirmBrowserImport(targetName string, cookies
if profileData.storage {
pterm.Printf(" Local storage — %s across %d origins\n", formatBinaryBytes(profileData.storageBytes), len(profileData.storageSites))
}
loginCount := 0
for _, provider := range logins.providers {
loginCount += len(provider.candidates)
}
if loginCount > 0 {
pterm.Printf(" Managed Auth connections — %d\n", loginCount)
}
pterm.Println()
return c.prompter.ConfirmDefault("import browser data", "Proceed?", true)
}
Expand Down Expand Up @@ -359,12 +352,14 @@ func buildSelectedProfileData(ctx context.Context, profile localbrowser.Profile,
counts["history"] = len(history)
}
if selection.storage {
storage, err := localbrowser.ExportLocalStorage(ctx, profile, selection.storageSites)
exported, err := localbrowser.ExportLocalStorage(ctx, profile, selection.storageSites)
if err != nil {
return localbrowser.ProfileData{}, nil, err
}
data.Storage = storage
counts["storage"] = len(storage)
data.Storage = exported.Records
data.StorageRecordsSkipped = exported.SkippedRecords
data.StorageOriginsSkipped = exported.SkippedOrigins
counts["storage"] = len(exported.Records)
}
return data, counts, nil
}
Expand Down Expand Up @@ -410,6 +405,30 @@ func importedStorageOriginCount(records []localbrowser.StorageRecord) int {
return len(origins)
}

type storageImportSummary struct {
importedOrigins int
importedEntries int
skippedOrigins int
skippedEntries int
}

func effectiveStorageImportSummary(applied localbrowser.AppliedProfile, requestedEntries, requestedOrigins int) storageImportSummary {
if applied.StorageEntriesImported == nil || applied.StorageOriginsImported == nil {
return storageImportSummary{importedOrigins: requestedOrigins, importedEntries: requestedEntries}
}
summary := storageImportSummary{
importedOrigins: *applied.StorageOriginsImported,
importedEntries: *applied.StorageEntriesImported,
}
if applied.StorageOriginsSkipped != nil {
summary.skippedOrigins = *applied.StorageOriginsSkipped
}
if applied.StorageEntriesSkipped != nil {
summary.skippedEntries = *applied.StorageEntriesSkipped
}
return summary
}

func formatBinaryBytes(bytes int64) string {
if bytes < 1<<20 {
return fmt.Sprintf("%.1f KiB", float64(bytes)/(1<<10))
Expand Down
155 changes: 155 additions & 0 deletions cmd/browser_import_profile_job.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package cmd

import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"

localbrowser "github.com/kernel/cli/internal/browserimport"
"github.com/pterm/pterm"
)

type profileImportStage int32

const (
profileImportStagePreparing profileImportStage = iota
profileImportStageUploading
profileImportStageApplying
profileImportStageReady
)

type browserProfileImportClient interface {
SubmitInventory(context.Context, string, string, localbrowser.Inventory) (localbrowser.Status, error)
SubmitSelection(context.Context, string, localbrowser.Selection) (localbrowser.Status, error)
Upload(context.Context, string, string, []byte) (localbrowser.Status, error)
Wait(context.Context, string, time.Duration) (localbrowser.Status, error)
WaitForProfile(context.Context, string, time.Duration) (localbrowser.Status, error)
}

type profileImportRequest struct {
importID string
helperToken string
dashboardHandoff bool
inventory localbrowser.Inventory
selection localbrowser.Selection
bundle []byte
waitTimeout time.Duration
}

type profileImportResult struct {
status localbrowser.Status
duration time.Duration
}

type profileImportJob struct {
cancel context.CancelFunc
stage atomic.Int32
done chan struct{}

mu sync.Mutex
result profileImportResult
err error
}

func startProfileImport(ctx context.Context, client browserProfileImportClient, request profileImportRequest) *profileImportJob {
jobCtx, cancel := context.WithCancel(ctx)
job := &profileImportJob{cancel: cancel, done: make(chan struct{})}
job.stage.Store(int32(profileImportStagePreparing))
go func() {
defer close(job.done)
result, err := runProfileImport(jobCtx, client, request, &job.stage)
job.mu.Lock()
job.result = result
job.err = err
job.mu.Unlock()
}()
return job
}

func runProfileImport(ctx context.Context, client browserProfileImportClient, request profileImportRequest, stage *atomic.Int32) (profileImportResult, error) {
startedAt := time.Now()
status, err := client.SubmitInventory(ctx, request.importID, request.helperToken, request.inventory)
if err != nil {
return profileImportResult{}, browserImportProgressError(request.importID, status.Phase, time.Since(startedAt), err)
}
status, err = client.SubmitSelection(ctx, request.importID, request.selection)
if err != nil {
return profileImportResult{}, browserImportProgressError(request.importID, status.Phase, time.Since(startedAt), err)
}
stage.Store(int32(profileImportStageUploading))
status, err = client.Upload(ctx, request.importID, request.helperToken, request.bundle)
if err != nil {
return profileImportResult{}, browserImportProgressError(request.importID, status.Phase, time.Since(startedAt), err)
}
stage.Store(int32(profileImportStageApplying))
waitCtx, cancel := context.WithTimeout(ctx, request.waitTimeout)
defer cancel()
if request.dashboardHandoff {
status, err = client.WaitForProfile(waitCtx, request.importID, 2*time.Second)
} else {
status, err = client.Wait(waitCtx, request.importID, 2*time.Second)
}
if err != nil {
return profileImportResult{}, fmt.Errorf("browser import %s did not complete: %w; check it with: kernel profiles import-status %s", request.importID, err, request.importID)
}
stage.Store(int32(profileImportStageReady))
return profileImportResult{status: status, duration: time.Since(startedAt)}, nil
}

func (j *profileImportJob) Stage() profileImportStage {
return profileImportStage(j.stage.Load())
}

func (j *profileImportJob) Wait(ctx context.Context) (profileImportResult, error) {
select {
case <-ctx.Done():
return profileImportResult{}, ctx.Err()
case <-j.done:
j.mu.Lock()
defer j.mu.Unlock()
return j.result, j.err
}
}

func (j *profileImportJob) Cancel() {
j.cancel()
}

func waitForProfileImport(ctx context.Context, job *profileImportJob, targetName string, humanOutput bool) (profileImportResult, error) {
if !humanOutput {
return job.Wait(ctx)
}
current := job.Stage()
progress, _ := pterm.DefaultProgressbar.
WithTotal(len(profileImportProgressStages)).
WithCurrent(int(current)).
WithTitle(fmt.Sprintf("%s: %q", profileImportProgressStages[current], targetName)).
WithShowElapsedTime().
Start()
defer progress.Stop()
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return profileImportResult{}, ctx.Err()
case <-job.done:
result, err := job.Wait(ctx)
if err == nil {
progress.Current = len(profileImportProgressStages)
progress.UpdateTitle(fmt.Sprintf("%s: %q", profileImportProgressStages[profileImportStageReady], targetName))
}
return result, err
case <-ticker.C:
next := job.Stage()
if next == current {
continue
}
current = next
progress.Current = int(current)
progress.UpdateTitle(fmt.Sprintf("%s: %q", profileImportProgressStages[current], targetName))
}
}
}
80 changes: 80 additions & 0 deletions cmd/browser_import_profile_job_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cmd

import (
"context"
"sync"
"testing"
"time"

localbrowser "github.com/kernel/cli/internal/browserimport"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type blockingProfileImportClient struct {
mu sync.Mutex
calls []string
waitCalled chan struct{}
release chan struct{}
}

func (c *blockingProfileImportClient) record(call string) {
c.mu.Lock()
defer c.mu.Unlock()
c.calls = append(c.calls, call)
}

func (c *blockingProfileImportClient) SubmitInventory(context.Context, string, string, localbrowser.Inventory) (localbrowser.Status, error) {
c.record("inventory")
return localbrowser.Status{Phase: "awaiting_selection"}, nil
}

func (c *blockingProfileImportClient) SubmitSelection(context.Context, string, localbrowser.Selection) (localbrowser.Status, error) {
c.record("selection")
return localbrowser.Status{Phase: "awaiting_upload"}, nil
}

func (c *blockingProfileImportClient) Upload(context.Context, string, string, []byte) (localbrowser.Status, error) {
c.record("upload")
return localbrowser.Status{Phase: "applying"}, nil
}

func (c *blockingProfileImportClient) Wait(context.Context, string, time.Duration) (localbrowser.Status, error) {
return c.wait()
}

func (c *blockingProfileImportClient) WaitForProfile(context.Context, string, time.Duration) (localbrowser.Status, error) {
return c.wait()
}

func (c *blockingProfileImportClient) wait() (localbrowser.Status, error) {
c.record("wait")
close(c.waitCalled)
<-c.release
return localbrowser.Status{Phase: "awaiting_client_completion", Applied: &localbrowser.Applied{Profiles: []localbrowser.AppliedProfile{{ProfileID: "prof_1"}}}}, nil
}

func TestProfileImportRunsWhileManagedAuthIsSelected(t *testing.T) {
client := &blockingProfileImportClient{waitCalled: make(chan struct{}), release: make(chan struct{})}
job := startProfileImport(t.Context(), client, profileImportRequest{
importID: "bri_1", helperToken: "grant", dashboardHandoff: true,
inventory: localbrowser.Inventory{}, selection: localbrowser.Selection{}, bundle: []byte("bundle"), waitTimeout: time.Minute,
})

select {
case <-client.waitCalled:
assert.Equal(t, profileImportStageApplying, job.Stage())
case <-time.After(time.Second):
t.Fatal("profile import did not reach server-side apply while the caller remained interactive")
}

client.mu.Lock()
assert.Equal(t, []string{"inventory", "selection", "upload", "wait"}, client.calls)
client.mu.Unlock()
close(client.release)

result, err := job.Wait(t.Context())
require.NoError(t, err)
assert.Equal(t, "prof_1", result.status.Applied.Profiles[0].ProfileID)
assert.Equal(t, profileImportStageReady, job.Stage())
}
1 change: 1 addition & 0 deletions cmd/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func runConnectorOpen(cmd *cobra.Command, args []string) error {
input := ProfilesImportLocalInput{
Days: 30,
ProjectID: link.ProjectID,
ImportID: link.ImportID,
Version: metadata.Version,
WaitTimeout: 30 * time.Minute,
DashboardLaunch: true,
Expand Down
Loading