## Summary <!-- Kurz: Was ändert sich und warum? --> ## Spec-Driven Development (SDD) - [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/` - [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md` - [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation) - [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert ## Implementation - [ ] Implementierung entspricht der Spec - [ ] Edge cases / Fehlerfälle berücksichtigt - [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes ## Tests - [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit) - [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`) ## Migration / Config / Ops (falls relevant) - [ ] Migration(en) enthalten und getestet - [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration) - [ ] Neue Env Vars dokumentiert (`.env.example` / Doku) - [ ] Queue/cron/storage Auswirkungen geprüft ## UI (Filament/Livewire) (falls relevant) - [ ] UI-Flows geprüft - [ ] Screenshots/Notizen hinzugefügt ## Notes <!-- Links, Screenshots, Follow-ups, offene Punkte --> Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #4
712 lines
20 KiB
Markdown
712 lines
20 KiB
Markdown
# Data Model: Feature 005 - Bulk Operations
|
|
|
|
**Feature**: Bulk Operations for Resource Management
|
|
**Date**: 2025-12-22
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
This document describes the data model for bulk operations, including new entities, schema changes, relationships, and query patterns.
|
|
|
|
---
|
|
|
|
## Entity Diagram
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ BulkOperationRun │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ id: bigint PK │
|
|
│ tenant_id: bigint FK → Tenant │
|
|
│ user_id: bigint FK → User │
|
|
│ resource: string (policies, policy_versions, etc.) │
|
|
│ action: string (delete, export, prune, etc.) │
|
|
│ status: enum (pending, running, completed, failed, aborted) │
|
|
│ total_items: int │
|
|
│ processed_items: int │
|
|
│ succeeded: int │
|
|
│ failed: int │
|
|
│ skipped: int │
|
|
│ item_ids: jsonb (array of IDs) │
|
|
│ failures: jsonb (array of {id, reason}) │
|
|
│ audit_log_id: bigint FK → AuditLog (nullable) │
|
|
│ created_at: timestamp │
|
|
│ updated_at: timestamp │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
│ │ │
|
|
│ │ └─────────────┐
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
│ Tenant │ │ User │ │ AuditLog │
|
|
└──────────────┘ └──────────────┘ └──────────────┘
|
|
|
|
|
|
┌─────────────────────────────────────────┐
|
|
│ Policy (Extended) │
|
|
├─────────────────────────────────────────┤
|
|
│ id: bigint PK │
|
|
│ tenant_id: bigint FK │
|
|
│ graph_id: string │
|
|
│ name: string │
|
|
│ ... (existing columns) │
|
|
│ deleted_at: timestamp (nullable) │
|
|
│ ignored_at: timestamp (nullable) ← NEW │
|
|
└─────────────────────────────────────────┘
|
|
|
|
|
|
┌─────────────────────────────────────────┐
|
|
│ PolicyVersion (Extended) │
|
|
├─────────────────────────────────────────┤
|
|
│ id: bigint PK │
|
|
│ policy_id: bigint FK → Policy │
|
|
│ is_current: boolean │
|
|
│ created_at: timestamp │
|
|
│ ... (existing columns) │
|
|
├─────────────────────────────────────────┤
|
|
│ Scope: pruneEligible() ← NEW │
|
|
│ WHERE is_current = false │
|
|
│ AND created_at < NOW() - 90 days │
|
|
│ AND NOT IN (backup_items) │
|
|
│ AND NOT IN (restore_runs.metadata) │
|
|
└─────────────────────────────────────────┘
|
|
|
|
|
|
┌─────────────────────────────────────────┐
|
|
│ RestoreRun (Extended) │
|
|
├─────────────────────────────────────────┤
|
|
│ id: bigint PK │
|
|
│ status: enum (pending, running, ...) │
|
|
│ ... (existing columns) │
|
|
├─────────────────────────────────────────┤
|
|
│ Scope: deletable() ← NEW │
|
|
│ WHERE status IN (completed, failed) │
|
|
└─────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Database Schema
|
|
|
|
### New Table: bulk_operation_runs
|
|
|
|
```sql
|
|
CREATE TABLE bulk_operation_runs (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
resource VARCHAR(50) NOT NULL, -- 'policies', 'policy_versions', 'backup_sets', 'restore_runs'
|
|
action VARCHAR(50) NOT NULL, -- 'delete', 'export', 'prune', 'archive'
|
|
status VARCHAR(20) NOT NULL, -- 'pending', 'running', 'completed', 'failed', 'aborted'
|
|
total_items INT NOT NULL,
|
|
processed_items INT NOT NULL DEFAULT 0,
|
|
succeeded INT NOT NULL DEFAULT 0,
|
|
failed INT NOT NULL DEFAULT 0,
|
|
skipped INT NOT NULL DEFAULT 0,
|
|
item_ids JSONB NOT NULL, -- [1, 2, 3, ...]
|
|
failures JSONB, -- [{"id": 1, "reason": "Graph error"}, ...]
|
|
audit_log_id BIGINT REFERENCES audit_logs(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_bulk_operation_runs_tenant_resource_status
|
|
ON bulk_operation_runs(tenant_id, resource, status);
|
|
|
|
CREATE INDEX idx_bulk_operation_runs_user_created
|
|
ON bulk_operation_runs(user_id, created_at DESC);
|
|
|
|
CREATE INDEX idx_bulk_operation_runs_status
|
|
ON bulk_operation_runs(status)
|
|
WHERE status IN ('pending', 'running');
|
|
```
|
|
|
|
**Indexes Rationale**:
|
|
- Composite (tenant_id, resource, status): Filter by tenant + resource type + status (UI queries)
|
|
- (user_id, created_at): User's recent bulk operations
|
|
- Partial index on status: Only index running/pending (99% of queries check these)
|
|
|
|
---
|
|
|
|
### Schema Change: policies table
|
|
|
|
```sql
|
|
ALTER TABLE policies
|
|
ADD COLUMN ignored_at TIMESTAMP NULL;
|
|
|
|
CREATE INDEX idx_policies_ignored_at
|
|
ON policies(ignored_at)
|
|
WHERE ignored_at IS NOT NULL;
|
|
```
|
|
|
|
**Purpose**: Prevent SyncPoliciesJob from re-importing locally deleted policies.
|
|
|
|
**Query Pattern**:
|
|
```sql
|
|
-- Sync job filters
|
|
SELECT * FROM policies WHERE ignored_at IS NULL;
|
|
|
|
-- Bulk delete sets
|
|
UPDATE policies SET ignored_at = NOW() WHERE id IN (...);
|
|
```
|
|
|
|
---
|
|
|
|
### Schema Change: policy_versions table
|
|
|
|
**No schema changes needed.** Eligibility scope uses existing columns:
|
|
- `is_current` (boolean)
|
|
- `created_at` (timestamp)
|
|
- Foreign keys checked via relationships
|
|
|
|
---
|
|
|
|
## Eloquent Models
|
|
|
|
### New Model: BulkOperationRun
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
|
class BulkOperationRun extends Model
|
|
{
|
|
protected $guarded = [];
|
|
|
|
protected $casts = [
|
|
'item_ids' => 'array',
|
|
'failures' => 'array',
|
|
'created_at' => 'datetime',
|
|
'updated_at' => 'datetime',
|
|
];
|
|
|
|
// Relationships
|
|
|
|
public function tenant(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Tenant::class);
|
|
}
|
|
|
|
public function user(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
public function auditLog(): BelongsTo
|
|
{
|
|
return $this->belongsTo(AuditLog::class);
|
|
}
|
|
|
|
// Status Helpers
|
|
|
|
public function isRunning(): bool
|
|
{
|
|
return $this->status === 'running';
|
|
}
|
|
|
|
public function isComplete(): bool
|
|
{
|
|
return in_array($this->status, ['completed', 'failed', 'aborted']);
|
|
}
|
|
|
|
public function isPending(): bool
|
|
{
|
|
return $this->status === 'pending';
|
|
}
|
|
|
|
// Progress Helpers
|
|
|
|
public function progressPercentage(): int
|
|
{
|
|
if ($this->total_items === 0) {
|
|
return 0;
|
|
}
|
|
return (int) round(($this->processed_items / $this->total_items) * 100);
|
|
}
|
|
|
|
public function summaryText(): string
|
|
{
|
|
return match ($this->status) {
|
|
'pending' => "Pending: {$this->total_items} items",
|
|
'running' => "Processing... {$this->processed_items}/{$this->total_items}",
|
|
'completed' => "Completed: {$this->succeeded} succeeded, {$this->failed} failed, {$this->skipped} skipped",
|
|
'failed' => "Failed: {$this->failed}/{$this->total_items} errors",
|
|
'aborted' => "Aborted: Too many failures ({$this->failed}/{$this->total_items})",
|
|
default => "Unknown status",
|
|
};
|
|
}
|
|
|
|
public function hasFailures(): bool
|
|
{
|
|
return $this->failed > 0;
|
|
}
|
|
|
|
// Scopes
|
|
|
|
public function scopeForResource($query, string $resource)
|
|
{
|
|
return $query->where('resource', $resource);
|
|
}
|
|
|
|
public function scopeForUser($query, int $userId)
|
|
{
|
|
return $query->where('user_id', $userId);
|
|
}
|
|
|
|
public function scopeRecent($query)
|
|
{
|
|
return $query->orderBy('created_at', 'desc');
|
|
}
|
|
|
|
public function scopeInProgress($query)
|
|
{
|
|
return $query->whereIn('status', ['pending', 'running']);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Extended Model: Policy
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
|
|
class Policy extends Model
|
|
{
|
|
use SoftDeletes;
|
|
|
|
// ... existing code ...
|
|
|
|
// NEW: Scopes for bulk operations
|
|
|
|
public function scopeNotIgnored($query)
|
|
{
|
|
return $query->whereNull('ignored_at');
|
|
}
|
|
|
|
public function scopeIgnored($query)
|
|
{
|
|
return $query->whereNotNull('ignored_at');
|
|
}
|
|
|
|
// NEW: Methods for bulk operations
|
|
|
|
public function markIgnored(): void
|
|
{
|
|
$this->update(['ignored_at' => now()]);
|
|
}
|
|
|
|
public function unmarkIgnored(): void
|
|
{
|
|
$this->update(['ignored_at' => null]);
|
|
}
|
|
|
|
public function isIgnored(): bool
|
|
{
|
|
return $this->ignored_at !== null;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Extended Model: PolicyVersion
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class PolicyVersion extends Model
|
|
{
|
|
// ... existing code ...
|
|
|
|
// Relationships (if not already defined)
|
|
|
|
public function backupItems()
|
|
{
|
|
return $this->hasMany(BackupItem::class, 'policy_version_id');
|
|
}
|
|
|
|
// NEW: Scope for eligibility check
|
|
|
|
public function scopePruneEligible($query, int $retentionDays = 90)
|
|
{
|
|
return $query
|
|
->where('is_current', false)
|
|
->where('created_at', '<', now()->subDays($retentionDays))
|
|
->whereDoesntHave('backupItems')
|
|
->whereNotIn('id', function ($subquery) {
|
|
$subquery->select(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)"))
|
|
->from('restore_runs')
|
|
->whereNotNull(DB::raw("metadata->>'policy_version_id'"));
|
|
});
|
|
}
|
|
|
|
// NEW: Check if version is eligible for pruning
|
|
|
|
public function isPruneEligible(int $retentionDays = 90): bool
|
|
{
|
|
if ($this->is_current) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->created_at->diffInDays(now()) < $retentionDays) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->backupItems()->exists()) {
|
|
return false;
|
|
}
|
|
|
|
$referencedInRestoreRuns = DB::table('restore_runs')
|
|
->whereNotNull(DB::raw("metadata->>'policy_version_id'"))
|
|
->where(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)"), $this->id)
|
|
->exists();
|
|
|
|
return !$referencedInRestoreRuns;
|
|
}
|
|
|
|
// NEW: Get reason why version is not eligible
|
|
|
|
public function getIneligibilityReason(int $retentionDays = 90): ?string
|
|
{
|
|
if ($this->is_current) {
|
|
return 'Current version';
|
|
}
|
|
|
|
if ($this->created_at->diffInDays(now()) < $retentionDays) {
|
|
return "Too recent (< {$retentionDays} days)";
|
|
}
|
|
|
|
if ($this->backupItems()->exists()) {
|
|
return 'Referenced in backup items';
|
|
}
|
|
|
|
$referencedInRestoreRuns = DB::table('restore_runs')
|
|
->whereNotNull(DB::raw("metadata->>'policy_version_id'"))
|
|
->where(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)"), $this->id)
|
|
->exists();
|
|
|
|
if ($referencedInRestoreRuns) {
|
|
return 'Referenced in restore runs';
|
|
}
|
|
|
|
return null; // Eligible
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Extended Model: RestoreRun
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class RestoreRun extends Model
|
|
{
|
|
// ... existing code ...
|
|
|
|
// NEW: Scope for deletable restore runs
|
|
|
|
public function scopeDeletable($query)
|
|
{
|
|
return $query->whereIn('status', ['completed', 'failed', 'aborted']);
|
|
}
|
|
|
|
public function scopeNotDeletable($query)
|
|
{
|
|
return $query->whereNotIn('status', ['completed', 'failed', 'aborted']);
|
|
}
|
|
|
|
// NEW: Check if restore run can be deleted
|
|
|
|
public function isDeletable(): bool
|
|
{
|
|
return in_array($this->status, ['completed', 'failed', 'aborted']);
|
|
}
|
|
|
|
public function getNotDeletableReason(): ?string
|
|
{
|
|
if ($this->isDeletable()) {
|
|
return null;
|
|
}
|
|
|
|
return match ($this->status) {
|
|
'pending' => 'Restore run is pending',
|
|
'running' => 'Restore run is currently running',
|
|
default => "Restore run has status: {$this->status}",
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Query Patterns
|
|
|
|
### 1. Create Bulk Operation Run
|
|
|
|
```php
|
|
$run = BulkOperationRun::create([
|
|
'tenant_id' => $tenantId,
|
|
'user_id' => $userId,
|
|
'resource' => 'policies',
|
|
'action' => 'delete',
|
|
'status' => 'pending',
|
|
'total_items' => count($policyIds),
|
|
'item_ids' => $policyIds,
|
|
]);
|
|
|
|
// Dispatch job
|
|
BulkPolicyDeleteJob::dispatch($policyIds, $tenantId, $userId, $run->id);
|
|
|
|
$run->update(['status' => 'running']);
|
|
```
|
|
|
|
### 2. Update Progress (in job)
|
|
|
|
```php
|
|
$run->update([
|
|
'processed_items' => $run->processed_items + $chunkSize,
|
|
'succeeded' => $run->succeeded + $successCount,
|
|
'failed' => $run->failed + $failCount,
|
|
'failures' => array_merge($run->failures ?? [], $newFailures),
|
|
]);
|
|
```
|
|
|
|
### 3. Query Recent Bulk Operations (UI)
|
|
|
|
```php
|
|
// User's recent operations
|
|
$runs = BulkOperationRun::forUser(auth()->id())
|
|
->recent()
|
|
->limit(10)
|
|
->get();
|
|
|
|
// Tenant's in-progress operations
|
|
$inProgress = BulkOperationRun::where('tenant_id', $tenantId)
|
|
->inProgress()
|
|
->get();
|
|
```
|
|
|
|
### 4. Filter Policies (exclude ignored)
|
|
|
|
```php
|
|
// Sync job
|
|
$policies = Policy::where('tenant_id', $tenantId)
|
|
->notIgnored()
|
|
->get();
|
|
|
|
// Bulk delete (mark as ignored)
|
|
Policy::whereIn('id', $policyIds)
|
|
->update(['ignored_at' => now()]);
|
|
```
|
|
|
|
### 5. Check Policy Version Eligibility
|
|
|
|
```php
|
|
// Get all eligible versions for tenant
|
|
$eligibleVersions = PolicyVersion::whereHas('policy', function ($query) use ($tenantId) {
|
|
$query->where('tenant_id', $tenantId);
|
|
})
|
|
->pruneEligible(90)
|
|
->get();
|
|
|
|
// Check single version
|
|
$version = PolicyVersion::find($id);
|
|
if (!$version->isPruneEligible()) {
|
|
$reason = $version->getIneligibilityReason();
|
|
// Skip with reason: "Referenced in backup items"
|
|
}
|
|
```
|
|
|
|
### 6. Filter Deletable Restore Runs
|
|
|
|
```php
|
|
// Get deletable runs
|
|
$deletableRuns = RestoreRun::where('tenant_id', $tenantId)
|
|
->deletable()
|
|
->get();
|
|
|
|
// Check individual run
|
|
$run = RestoreRun::find($id);
|
|
if (!$run->isDeletable()) {
|
|
$reason = $run->getNotDeletableReason();
|
|
// Skip: "Restore run is currently running"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## JSONB Structure
|
|
|
|
### bulk_operation_runs.item_ids
|
|
|
|
```json
|
|
[1, 2, 3, 4, 5, ...]
|
|
```
|
|
|
|
Simple array of integer IDs.
|
|
|
|
### bulk_operation_runs.failures
|
|
|
|
```json
|
|
[
|
|
{
|
|
"id": 123,
|
|
"reason": "Graph API error: 503 Service Unavailable"
|
|
},
|
|
{
|
|
"id": 456,
|
|
"reason": "Policy not found"
|
|
},
|
|
{
|
|
"id": 789,
|
|
"reason": "Permission denied"
|
|
}
|
|
]
|
|
```
|
|
|
|
Array of objects with `id` (resource ID) and `reason` (error message).
|
|
|
|
### restore_runs.metadata (existing)
|
|
|
|
```json
|
|
{
|
|
"policy_version_id": 42,
|
|
"backup_set_id": 15,
|
|
"items_count": 10,
|
|
...
|
|
}
|
|
```
|
|
|
|
When checking eligibility, query:
|
|
```sql
|
|
SELECT * FROM restore_runs
|
|
WHERE metadata->>'policy_version_id' = '42';
|
|
```
|
|
|
|
---
|
|
|
|
## Migration Files
|
|
|
|
### Migration 1: Create bulk_operation_runs table
|
|
|
|
```php
|
|
<?php
|
|
|
|
use Illuminate\Database\Migrations\Migration;
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
return new class extends Migration
|
|
{
|
|
public function up(): void
|
|
{
|
|
Schema::create('bulk_operation_runs', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
|
$table->string('resource', 50);
|
|
$table->string('action', 50);
|
|
$table->string('status', 20);
|
|
$table->integer('total_items');
|
|
$table->integer('processed_items')->default(0);
|
|
$table->integer('succeeded')->default(0);
|
|
$table->integer('failed')->default(0);
|
|
$table->integer('skipped')->default(0);
|
|
$table->json('item_ids');
|
|
$table->json('failures')->nullable();
|
|
$table->foreignId('audit_log_id')->nullable()->constrained()->nullOnDelete();
|
|
$table->timestamps();
|
|
|
|
$table->index(['tenant_id', 'resource', 'status']);
|
|
$table->index(['user_id', 'created_at']);
|
|
$table->index('status')->where('status', 'in', ['pending', 'running']);
|
|
});
|
|
}
|
|
|
|
public function down(): void
|
|
{
|
|
Schema::dropIfExists('bulk_operation_runs');
|
|
}
|
|
};
|
|
```
|
|
|
|
### Migration 2: Add ignored_at to policies
|
|
|
|
```php
|
|
<?php
|
|
|
|
use Illuminate\Database\Migrations\Migration;
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
return new class extends Migration
|
|
{
|
|
public function up(): void
|
|
{
|
|
Schema::table('policies', function (Blueprint $table) {
|
|
$table->timestamp('ignored_at')->nullable()->after('deleted_at');
|
|
$table->index('ignored_at');
|
|
});
|
|
}
|
|
|
|
public function down(): void
|
|
{
|
|
Schema::table('policies', function (Blueprint $table) {
|
|
$table->dropIndex(['ignored_at']);
|
|
$table->dropColumn('ignored_at');
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Data Retention & Cleanup
|
|
|
|
### BulkOperationRun Retention
|
|
|
|
Recommended: Keep for 90 days, then archive or delete completed runs.
|
|
|
|
```php
|
|
// Scheduled command
|
|
Artisan::command('bulk-operations:prune', function () {
|
|
$deleted = BulkOperationRun::where('created_at', '<', now()->subDays(90))
|
|
->whereIn('status', ['completed', 'failed', 'aborted'])
|
|
->delete();
|
|
|
|
$this->info("Pruned {$deleted} old bulk operation runs");
|
|
})->daily();
|
|
```
|
|
|
|
### PolicyVersion Retention
|
|
|
|
Handled by bulk prune feature (user-initiated, not automatic).
|
|
|
|
---
|
|
|
|
**Status**: Data Model Complete
|
|
**Next Step**: Generate quickstart.md
|