Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
14 KiB
14 KiB
StellaOps Offline-First Implementation Guide
Overview
StellaOps supports offline operation for air-gapped environments. This document describes the implementation strategy for offline mode detection, data caching, and graceful degradation.
Architecture
Offline Mode States
┌─────────────────────────────────────────────────────────────────┐
│ Offline Mode State Machine │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ /health fails ┌──────────────┐ │
│ │ ONLINE │ ─────────────────> │ DEGRADED │ │
│ └─────────┘ 3 retries └──────────────┘ │
│ ↑ │ │
│ │ /health succeeds │ 30s timeout │
│ │ ↓ │
│ │ ┌──────────────┐ │
│ └───────────────────────── │ OFFLINE │ │
│ manual reconnect └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Mode Behaviors
| State | Read Operations | Write Operations | UI Indicators |
|---|---|---|---|
| Online | Live data | Enabled | None |
| Degraded | Live + cache fallback | Enabled with queue | Yellow banner |
| Offline | Cache only | Disabled | Red banner |
Service Worker Strategy
Caching Strategy
// sw-config.ts
const cacheStrategies = {
// Static assets: Cache-first
static: {
pattern: /\.(js|css|woff2?|png|svg)$/,
strategy: 'cache-first',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
},
// API contracts: Stale-while-revalidate
apiContracts: {
pattern: /\/api\/v1\/openapi/,
strategy: 'stale-while-revalidate',
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
// Dynamic data: Network-first with cache fallback
data: {
pattern: /\/api\/v1\/(findings|artifacts|policies)/,
strategy: 'network-first',
maxAge: 60 * 60 * 1000, // 1 hour
fallbackCache: true,
},
// Mutations: Network-only with queue
mutations: {
pattern: /\/api\/v1\/.+\/(create|update|delete)/,
strategy: 'network-only',
queueOffline: true,
},
};
Service Worker Registration
// main.ts
if ('serviceWorker' in navigator && environment.production) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered:', registration);
// Check for updates hourly
setInterval(() => registration.update(), 60 * 60 * 1000);
})
.catch(error => {
console.error('SW registration failed:', error);
});
});
}
IndexedDB Schema
Database Structure
// db-schema.ts
const DB_NAME = 'stellaops-offline';
const DB_VERSION = 1;
interface OfflineDatabase {
// Cached API responses
apiCache: {
key: string; // API endpoint + params hash
value: {
data: any;
timestamp: number;
etag?: string;
};
};
// Pending mutations
mutationQueue: {
id: string; // UUID
endpoint: string;
method: 'POST' | 'PUT' | 'DELETE';
body: any;
timestamp: number;
retries: number;
};
// Offline bundle assets
bundleAssets: {
hash: string; // Content hash
type: 'jwks' | 'advisory' | 'vex' | 'manifest';
data: ArrayBuffer;
importedAt: number;
};
// User preferences
preferences: {
key: string;
value: any;
};
}
IndexedDB Operations
// offline-db.service.ts
@Injectable({ providedIn: 'root' })
export class OfflineDbService {
private db: IDBDatabase | null = null;
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// API cache store
const apiCache = db.createObjectStore('apiCache', { keyPath: 'key' });
apiCache.createIndex('timestamp', 'timestamp');
// Mutation queue store
const mutations = db.createObjectStore('mutationQueue', { keyPath: 'id' });
mutations.createIndex('timestamp', 'timestamp');
// Bundle assets store
const bundles = db.createObjectStore('bundleAssets', { keyPath: 'hash' });
bundles.createIndex('type', 'type');
// Preferences store
db.createObjectStore('preferences', { keyPath: 'key' });
};
});
}
async getCached<T>(key: string): Promise<T | null> {
return this.get<{ data: T }>('apiCache', key).then(r => r?.data ?? null);
}
async setCached<T>(key: string, data: T, etag?: string): Promise<void> {
await this.put('apiCache', {
key,
value: { data, timestamp: Date.now(), etag },
});
}
async queueMutation(mutation: Omit<MutationQueue, 'id' | 'timestamp' | 'retries'>): Promise<string> {
const id = crypto.randomUUID();
await this.put('mutationQueue', {
...mutation,
id,
timestamp: Date.now(),
retries: 0,
});
return id;
}
}
Offline Mode Service
Mode Detection
// offline-mode.service.ts
@Injectable({ providedIn: 'root' })
export class OfflineModeService {
private readonly healthUrl = '/health';
private readonly maxRetries = 3;
private readonly retryDelay = 1000;
readonly mode$ = new BehaviorSubject<'online' | 'degraded' | 'offline'>('online');
readonly bundleInfo$ = new BehaviorSubject<BundleInfo | null>(null);
constructor(private http: HttpClient) {
this.startHealthPolling();
window.addEventListener('online', () => this.checkHealth());
window.addEventListener('offline', () => this.mode$.next('offline'));
}
private async checkHealth(): Promise<boolean> {
for (let i = 0; i < this.maxRetries; i++) {
try {
await firstValueFrom(
this.http.get(this.healthUrl, { responseType: 'text' })
.pipe(timeout(3000))
);
this.mode$.next('online');
return true;
} catch {
await this.delay(this.retryDelay * Math.pow(2, i));
}
}
const currentMode = this.mode$.value;
this.mode$.next(currentMode === 'online' ? 'degraded' : 'offline');
return false;
}
private startHealthPolling(): void {
interval(30000).pipe(
startWith(0),
switchMap(() => this.checkHealth())
).subscribe();
}
async importBundle(file: File): Promise<void> {
const manifest = await this.extractManifest(file);
await this.validateSignature(manifest);
await this.storeAssets(file, manifest);
this.bundleInfo$.next({
version: manifest.version,
createdAt: new Date(manifest.created_at),
expiresAt: new Date(manifest.expires_at),
});
}
}
Graceful Degradation
Read-Only Guard
// read-only.guard.ts
@Injectable({ providedIn: 'root' })
export class ReadOnlyGuard implements CanActivate {
constructor(
private offlineMode: OfflineModeService,
private toast: ToastService
) {}
canActivate(route: ActivatedRouteSnapshot): boolean {
const requiresWrite = route.data['requiresWrite'] as boolean;
const isOffline = this.offlineMode.mode$.value === 'offline';
if (requiresWrite && isOffline) {
this.toast.warning('This action requires an online connection');
return false;
}
return true;
}
}
Offline Banner Component
// offline-banner.component.ts
@Component({
selector: 'app-offline-banner',
template: `
<div *ngIf="mode$ | async as mode"
[class]="'offline-banner offline-banner--' + mode"
role="status"
aria-live="polite">
<ng-container [ngSwitch]="mode">
<ng-container *ngSwitchCase="'degraded'">
<mat-icon>wifi_off</mat-icon>
<span>Connection unstable. Some features may be limited.</span>
<button mat-button (click)="retry()">Retry</button>
</ng-container>
<ng-container *ngSwitchCase="'offline'">
<mat-icon>cloud_off</mat-icon>
<span>
Offline Mode - Data as of {{ bundleDate | date:'medium' }}
</span>
<button mat-button (click)="importBundle()">Import Bundle</button>
</ng-container>
</ng-container>
</div>
`,
})
export class OfflineBannerComponent {
mode$ = this.offlineMode.mode$;
bundleDate = this.offlineMode.bundleInfo$.value?.createdAt;
constructor(private offlineMode: OfflineModeService) {}
}
Disabled Mutation Buttons
// mutation-button.directive.ts
@Directive({ selector: '[appMutationButton]' })
export class MutationButtonDirective implements OnInit, OnDestroy {
private sub!: Subscription;
constructor(
private el: ElementRef<HTMLButtonElement>,
private offlineMode: OfflineModeService
) {}
ngOnInit(): void {
this.sub = this.offlineMode.mode$.subscribe(mode => {
const button = this.el.nativeElement;
const isOffline = mode === 'offline';
button.disabled = isOffline;
button.title = isOffline ? 'Requires online connection' : '';
button.classList.toggle('mutation-disabled', isOffline);
});
}
ngOnDestroy(): void {
this.sub.unsubscribe();
}
}
Bundle Freshness
Freshness Indicators
// bundle-freshness.component.ts
@Component({
selector: 'app-bundle-freshness',
template: `
<div class="bundle-freshness" [class]="'freshness--' + status">
<mat-icon>{{ icon }}</mat-icon>
<span>{{ message }}</span>
<span class="age">Updated {{ age | relativeTime }}</span>
</div>
`,
})
export class BundleFreshnessComponent {
@Input() bundleDate!: Date;
get status(): 'fresh' | 'stale' | 'expired' {
const ageMs = Date.now() - this.bundleDate.getTime();
const ageDays = ageMs / (24 * 60 * 60 * 1000);
if (ageDays < 7) return 'fresh';
if (ageDays < 30) return 'stale';
return 'expired';
}
get icon(): string {
switch (this.status) {
case 'fresh': return 'check_circle';
case 'stale': return 'warning';
case 'expired': return 'error';
}
}
get message(): string {
switch (this.status) {
case 'fresh': return 'Data is current';
case 'stale': return 'Data may be outdated';
case 'expired': return 'Data is seriously outdated';
}
}
}
Mutation Queue Sync
Queue Processing
// mutation-sync.service.ts
@Injectable({ providedIn: 'root' })
export class MutationSyncService {
private processing = false;
constructor(
private db: OfflineDbService,
private http: HttpClient,
private offlineMode: OfflineModeService
) {
// Process queue when coming online
this.offlineMode.mode$.pipe(
filter(mode => mode === 'online'),
switchMap(() => this.processQueue())
).subscribe();
}
async processQueue(): Promise<void> {
if (this.processing) return;
this.processing = true;
try {
const mutations = await this.db.getPendingMutations();
for (const mutation of mutations) {
try {
await firstValueFrom(
this.http.request(mutation.method, mutation.endpoint, {
body: mutation.body,
})
);
await this.db.removeMutation(mutation.id);
} catch (error) {
await this.db.incrementRetries(mutation.id);
if (mutation.retries >= 3) {
await this.db.moveToDlq(mutation.id);
}
}
}
} finally {
this.processing = false;
}
}
}
Testing Offline Mode
E2E Test Helpers
// offline.e2e-spec.ts
describe('Offline Mode', () => {
beforeEach(() => {
// Intercept health check
cy.intercept('/health', { forceNetworkError: true });
});
it('should show offline banner after health check fails', () => {
cy.visit('/');
cy.get('.offline-banner').should('be.visible');
cy.get('.offline-banner').should('contain', 'Offline Mode');
});
it('should disable mutation buttons in offline mode', () => {
cy.visit('/findings');
cy.get('[data-testid="create-vex-button"]').should('be.disabled');
});
it('should show cached data in offline mode', () => {
// Pre-populate cache
cy.window().then(win => {
win.localStorage.setItem('apiCache', JSON.stringify({
'/api/v1/findings': mockFindings,
}));
});
cy.visit('/findings');
cy.get('[data-testid="findings-table"]').should('have.length.gt', 0);
});
});