Compare commits
14 Commits
dev-fa
...
dev/phase-4c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5147c6bddb | |||
| e7c64e583b | |||
| f658d754a8 | |||
| a7b1fd2ae3 | |||
| ee1b56e833 | |||
| 2d6aabab9f | |||
| 54d6e6be36 | |||
| 7196b9f264 | |||
| f675032786 | |||
| cff6701864 | |||
| 469d900ac8 | |||
| 366941feb1 | |||
| df6d53c12c | |||
| d384af0d9c |
@@ -21,7 +21,7 @@ NATS_URL=nats://localhost:4222
|
|||||||
|
|
||||||
# ─── Federation (ActivityPub) ─────────────────────────────────────────────────
|
# ─── Federation (ActivityPub) ─────────────────────────────────────────────────
|
||||||
# Public URL of this instance (no trailing slash)
|
# Public URL of this instance (no trailing slash)
|
||||||
INSTANCE_URL=https://forgebucket.asgardlabs.net
|
INSTANCE_URL=https://forgebucket.dokploy.second-breakfast.dev
|
||||||
INSTANCE_NAME=ForgeBucket
|
INSTANCE_NAME=ForgeBucket
|
||||||
|
|
||||||
# ─── OIDC / OAuth2 (optional) ────────────────────────────────────────────────
|
# ─── OIDC / OAuth2 (optional) ────────────────────────────────────────────────
|
||||||
@@ -31,16 +31,16 @@ INSTANCE_NAME=ForgeBucket
|
|||||||
|
|
||||||
# ─── Dev only ─────────────────────────────────────────────────────────────────
|
# ─── Dev only ─────────────────────────────────────────────────────────────────
|
||||||
# Set to true to disable Secure cookies and enable verbose logging
|
# Set to true to disable Secure cookies and enable verbose logging
|
||||||
DEBUG=true
|
DEBUG=false
|
||||||
|
|
||||||
# PEM-encoded ECDSA P-256 private key. If empty, an ephemeral key is generated
|
# PEM-encoded ECDSA P-256 private key. If empty, an ephemeral key is generated
|
||||||
# at startup (signatures will not survive restart). Generate with:
|
# at startup (signatures will not survive restart). Generate with:
|
||||||
# openssl ecparam -genkey -name prime256v1 -noout -out signing-key.pem
|
# openssl ecparam -genkey -name prime256v1 -noout -out signing-key.pem
|
||||||
ARTIFACT_SIGNING_KEY="-----BEGIN EC PRIVATE KEY-----
|
# ARTIFACT_SIGNING_KEY="-----BEGIN EC PRIVATE KEY-----
|
||||||
MHcCAQEEIKGMjCu0NdczHQ7BRDeo0hTOLauF9vOenWl0HlyN4bzToAoGCCqGSM49
|
# MHcCAQEEIKGMjCu0NdczHQ7BRDeo0hTOLauF9vOenWl0HlyN4bzToAoGCCqGSM49
|
||||||
AwEHoUQDQgAE+VL1HhQ1us0QfNH+5Var8lo5Oww83B+QDQ2obzHL4JZl0UM3kVAB
|
# AwEHoUQDQgAE+VL1HhQ1us0QfNH+5Var8lo5Oww83B+QDQ2obzHL4JZl0UM3kVAB
|
||||||
SePwUlkfdW6u4a0KYMYf3Op6wsXTp0kA2g==
|
# SePwUlkfdW6u4a0KYMYf3Op6wsXTp0kA2g==
|
||||||
-----END EC PRIVATE KEY-----"
|
# -----END EC PRIVATE KEY-----"
|
||||||
|
|
||||||
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
|
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
|
||||||
# Root directory for the OCI Distribution Spec blob and upload storage.
|
# Root directory for the OCI Distribution Spec blob and upload storage.
|
||||||
|
|||||||
+1
-2
@@ -13,5 +13,4 @@ uploads
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
|
html docs/
|
||||||
ai_agent_master_prompt_for_building_modern_git_platform.md
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ Logger → RealIP → Recoverer → Metrics → CORS → CSRF → SessionAuth
|
|||||||
| 3E | Observability (Prometheus `/metrics`, structured `/health`, repo health API) | **Complete** |
|
| 3E | Observability (Prometheus `/metrics`, structured `/health`, repo health API) | **Complete** |
|
||||||
| 3F | Federation handlers (ActivityPub WebFinger, actor, inbox/outbox, HTTP signatures, Follow/Accept) | **Complete** |
|
| 3F | Federation handlers (ActivityPub WebFinger, actor, inbox/outbox, HTTP signatures, Follow/Accept) | **Complete** |
|
||||||
| 4 | SBOM generation, secret scanning, vuln scanning, signed artifacts, OCI registry, security page | **Complete** |
|
| 4 | SBOM generation, secret scanning, vuln scanning, signed artifacts, OCI registry, security page | **Complete** |
|
||||||
| 5 | AI diagnostics, deployment promotions, rollback visualization | Planned |
|
| 5 | Deployment promotions, rollback visualization | Planned |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -9,8 +9,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Planned — Phase 5 (AI Diagnostics + Deployment Promotions + Rollback Visualization)
|
### Planned — Phase 5 (Deployment Promotions + Rollback Visualization)
|
||||||
- AI-powered pipeline failure diagnostics
|
|
||||||
- Deployment promotion workflows (manual + automated)
|
- Deployment promotion workflows (manual + automated)
|
||||||
- Rollback visualization and timeline
|
- Rollback visualization and timeline
|
||||||
|
|
||||||
|
|||||||
@@ -1,741 +0,0 @@
|
|||||||
# MASTER IMPLEMENTATION PROMPT
|
|
||||||
## Building a Modern Git Platform with CI/CD + GitOps + Operational UX
|
|
||||||
|
|
||||||
You are an elite senior staff engineer, platform architect, DevOps architect, distributed systems engineer, and product UX engineer.
|
|
||||||
|
|
||||||
You are tasked with designing and implementing a next-generation self-hosted Git platform.
|
|
||||||
|
|
||||||
This platform exists in the category of:
|
|
||||||
- GitHub
|
|
||||||
- GitLab
|
|
||||||
- Forgejo
|
|
||||||
- Gitea
|
|
||||||
- Bitbucket
|
|
||||||
|
|
||||||
BUT:
|
|
||||||
|
|
||||||
You must NOT merely clone existing platforms.
|
|
||||||
|
|
||||||
The goal is to create:
|
|
||||||
|
|
||||||
> a modern developer operations platform
|
|
||||||
|
|
||||||
that deeply integrates:
|
|
||||||
- Git hosting
|
|
||||||
- pull requests
|
|
||||||
- CI/CD
|
|
||||||
- GitOps
|
|
||||||
- deployments
|
|
||||||
- environments
|
|
||||||
- observability
|
|
||||||
- security
|
|
||||||
- operational awareness
|
|
||||||
- developer productivity
|
|
||||||
|
|
||||||
The platform should feel:
|
|
||||||
- fast
|
|
||||||
- operational
|
|
||||||
- developer-first
|
|
||||||
- low cognitive load
|
|
||||||
- modern
|
|
||||||
- reliable
|
|
||||||
- modular
|
|
||||||
- keyboard-first
|
|
||||||
- context-aware
|
|
||||||
|
|
||||||
The product philosophy is:
|
|
||||||
|
|
||||||
> repositories are operational systems
|
|
||||||
|
|
||||||
NOT:
|
|
||||||
|
|
||||||
> collections of files.
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
HIGH LEVEL PRODUCT GOALS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
The platform should optimize for:
|
|
||||||
|
|
||||||
- immediate situational awareness
|
|
||||||
- workflow continuity
|
|
||||||
- deployment visibility
|
|
||||||
- operational clarity
|
|
||||||
- reliability
|
|
||||||
- developer flow
|
|
||||||
- observability
|
|
||||||
- intelligent automation
|
|
||||||
- security by default
|
|
||||||
- excellent UX
|
|
||||||
|
|
||||||
Users should always be able to answer:
|
|
||||||
|
|
||||||
- what changed?
|
|
||||||
- what failed?
|
|
||||||
- what deployed?
|
|
||||||
- what is unhealthy?
|
|
||||||
- what needs my attention?
|
|
||||||
- what environments are affected?
|
|
||||||
- what should I review next?
|
|
||||||
- what caused this incident?
|
|
||||||
|
|
||||||
without navigating deeply.
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
CORE PRODUCT PRINCIPLES
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
1. UX FIRST
|
|
||||||
|
|
||||||
Most Git platforms prioritize functionality over usability.
|
|
||||||
|
|
||||||
This platform must prioritize:
|
|
||||||
- readability
|
|
||||||
- discoverability
|
|
||||||
- contextual awareness
|
|
||||||
- progressive disclosure
|
|
||||||
- speed
|
|
||||||
- low noise
|
|
||||||
- operational understanding
|
|
||||||
|
|
||||||
Avoid:
|
|
||||||
- tab overload
|
|
||||||
- enterprise clutter
|
|
||||||
- giant forms
|
|
||||||
- notification spam
|
|
||||||
- YAML-centric UX
|
|
||||||
- log-centric UX
|
|
||||||
|
|
||||||
The system should feel:
|
|
||||||
- intentional
|
|
||||||
- calm
|
|
||||||
- efficient
|
|
||||||
- operationally intelligent
|
|
||||||
|
|
||||||
--------------------------------------------------
|
|
||||||
|
|
||||||
2. OPERATIONAL AWARENESS
|
|
||||||
|
|
||||||
The platform must continuously surface:
|
|
||||||
- failing pipelines
|
|
||||||
- deployment health
|
|
||||||
- environment drift
|
|
||||||
- flaky tests
|
|
||||||
- security issues
|
|
||||||
- review bottlenecks
|
|
||||||
- dependency risks
|
|
||||||
- deployment risks
|
|
||||||
|
|
||||||
This information should appear naturally throughout the UI.
|
|
||||||
|
|
||||||
--------------------------------------------------
|
|
||||||
|
|
||||||
3. REPOSITORIES ARE RUNTIME SYSTEMS
|
|
||||||
|
|
||||||
The repository page should not simply display files.
|
|
||||||
|
|
||||||
It should display:
|
|
||||||
- deployments
|
|
||||||
- environments
|
|
||||||
- active PRs
|
|
||||||
- operational health
|
|
||||||
- timelines
|
|
||||||
- incidents
|
|
||||||
- ownership
|
|
||||||
- risk
|
|
||||||
- observability
|
|
||||||
|
|
||||||
The file tree is secondary.
|
|
||||||
|
|
||||||
--------------------------------------------------
|
|
||||||
|
|
||||||
4. GITOPS IS FIRST CLASS
|
|
||||||
|
|
||||||
Git should become:
|
|
||||||
- source of truth
|
|
||||||
- deployment history
|
|
||||||
- environment state definition
|
|
||||||
- rollback system
|
|
||||||
- audit log
|
|
||||||
|
|
||||||
The system must support:
|
|
||||||
- reconciliation
|
|
||||||
- drift detection
|
|
||||||
- declarative environments
|
|
||||||
- promotion workflows
|
|
||||||
- rollback visualization
|
|
||||||
- environment topology
|
|
||||||
|
|
||||||
--------------------------------------------------
|
|
||||||
|
|
||||||
5. RELIABILITY IS A PRIMARY FEATURE
|
|
||||||
|
|
||||||
The platform should optimize for:
|
|
||||||
- deterministic execution
|
|
||||||
- idempotency
|
|
||||||
- isolation
|
|
||||||
- resilience
|
|
||||||
- queue durability
|
|
||||||
- observability
|
|
||||||
- safe rollbacks
|
|
||||||
- fault tolerance
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
ARCHITECTURE REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Design the platform using modular services.
|
|
||||||
|
|
||||||
Suggested architecture:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
Platform
|
|
||||||
├── API Gateway
|
|
||||||
├── Auth Service
|
|
||||||
├── Repository Service
|
|
||||||
├── Pull Request Service
|
|
||||||
├── Issue Service
|
|
||||||
├── Event Bus
|
|
||||||
├── CI Orchestrator
|
|
||||||
├── Runner Manager
|
|
||||||
├── Artifact Registry
|
|
||||||
├── Package Registry
|
|
||||||
├── Deployment Engine
|
|
||||||
├── GitOps Controller
|
|
||||||
├── Environment Service
|
|
||||||
├── Secret Manager
|
|
||||||
├── Notification Service
|
|
||||||
├── Search Service
|
|
||||||
├── Observability Layer
|
|
||||||
├── Metrics Service
|
|
||||||
├── Audit Service
|
|
||||||
└── Web Frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
EVENT DRIVEN ARCHITECTURE
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
The platform should be event-driven.
|
|
||||||
|
|
||||||
Everything emits events.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
repo.created
|
|
||||||
push.created
|
|
||||||
pr.opened
|
|
||||||
review.requested
|
|
||||||
pipeline.started
|
|
||||||
pipeline.failed
|
|
||||||
artifact.published
|
|
||||||
deployment.started
|
|
||||||
deployment.failed
|
|
||||||
environment.drift_detected
|
|
||||||
incident.created
|
|
||||||
```
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- durable events
|
|
||||||
- replay support
|
|
||||||
- auditability
|
|
||||||
- timeline reconstruction
|
|
||||||
- reactive UI updates
|
|
||||||
|
|
||||||
Recommended technologies:
|
|
||||||
- NATS
|
|
||||||
- Kafka
|
|
||||||
- RabbitMQ
|
|
||||||
|
|
||||||
Prefer NATS initially for simplicity.
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
CI/CD SYSTEM REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
The CI/CD system must support:
|
|
||||||
|
|
||||||
- DAG pipelines
|
|
||||||
- matrix builds
|
|
||||||
- reusable workflows
|
|
||||||
- workflow templates
|
|
||||||
- conditional execution
|
|
||||||
- parallel execution
|
|
||||||
- artifacts
|
|
||||||
- caches
|
|
||||||
- retries
|
|
||||||
- concurrency controls
|
|
||||||
- cancellation
|
|
||||||
- pipeline graphs
|
|
||||||
- environment promotion
|
|
||||||
- rollback support
|
|
||||||
- preview environments
|
|
||||||
- flaky test detection
|
|
||||||
- deployment visualization
|
|
||||||
|
|
||||||
The orchestrator should:
|
|
||||||
- schedule
|
|
||||||
- coordinate
|
|
||||||
- monitor
|
|
||||||
|
|
||||||
NOT directly execute jobs.
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
RUNNER SYSTEM REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Runners should:
|
|
||||||
- be ephemeral
|
|
||||||
- isolated
|
|
||||||
- disposable
|
|
||||||
- reproducible
|
|
||||||
- stateless
|
|
||||||
|
|
||||||
Support execution backends:
|
|
||||||
|
|
||||||
1. Docker containers
|
|
||||||
2. Kubernetes jobs
|
|
||||||
3. Firecracker microVMs
|
|
||||||
4. Bare metal runners
|
|
||||||
|
|
||||||
Security is critical.
|
|
||||||
|
|
||||||
Forked PRs must never automatically receive secrets.
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
SECRET MANAGEMENT
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Implement scoped secret management.
|
|
||||||
|
|
||||||
Secret hierarchy:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
Global
|
|
||||||
→ Organization
|
|
||||||
→ Repository
|
|
||||||
→ Environment
|
|
||||||
→ Runtime ephemeral injection
|
|
||||||
```
|
|
||||||
|
|
||||||
Support:
|
|
||||||
- Vault
|
|
||||||
- OIDC federation
|
|
||||||
- cloud secret providers
|
|
||||||
- encrypted secret storage
|
|
||||||
|
|
||||||
Secrets must:
|
|
||||||
- never leak to logs
|
|
||||||
- be masked automatically
|
|
||||||
- support audit trails
|
|
||||||
- support rotation
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
ARTIFACT + PACKAGE MANAGEMENT
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Implement:
|
|
||||||
- build artifact storage
|
|
||||||
- OCI registry
|
|
||||||
- package registries
|
|
||||||
- retention policies
|
|
||||||
- artifact provenance
|
|
||||||
- signing support
|
|
||||||
- SBOM support
|
|
||||||
|
|
||||||
Support:
|
|
||||||
- container images
|
|
||||||
- npm
|
|
||||||
- cargo
|
|
||||||
- pip
|
|
||||||
- maven
|
|
||||||
- generic artifacts
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
GITOPS REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
GitOps must be deeply integrated.
|
|
||||||
|
|
||||||
Support:
|
|
||||||
- reconciliation loops
|
|
||||||
- drift detection
|
|
||||||
- sync visualization
|
|
||||||
- environment topology
|
|
||||||
- deployment promotion
|
|
||||||
- rollback flows
|
|
||||||
- canary releases
|
|
||||||
- blue/green deployments
|
|
||||||
- environment history
|
|
||||||
|
|
||||||
The UI should make GitOps understandable.
|
|
||||||
|
|
||||||
Avoid overwhelming Kubernetes-centric UX.
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
DATABASE + STORAGE DESIGN
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Separate:
|
|
||||||
- application code
|
|
||||||
- repository storage
|
|
||||||
- artifacts
|
|
||||||
- caches
|
|
||||||
- logs
|
|
||||||
- uploads
|
|
||||||
|
|
||||||
Repository storage should NOT exist in the app repository.
|
|
||||||
|
|
||||||
Recommended:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
/data/repos
|
|
||||||
/data/artifacts
|
|
||||||
/data/cache
|
|
||||||
/data/uploads
|
|
||||||
```
|
|
||||||
|
|
||||||
Repository storage is runtime instance data.
|
|
||||||
|
|
||||||
Treat it like:
|
|
||||||
- database files
|
|
||||||
- object storage
|
|
||||||
- runtime state
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
DASHBOARD REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
The dashboard should behave like:
|
|
||||||
|
|
||||||
> an operational command center
|
|
||||||
|
|
||||||
NOT:
|
|
||||||
|
|
||||||
> a repository list.
|
|
||||||
|
|
||||||
Include:
|
|
||||||
|
|
||||||
1. Attention Required
|
|
||||||
- failing pipelines
|
|
||||||
- stale PRs
|
|
||||||
- security alerts
|
|
||||||
- deployment failures
|
|
||||||
- environment drift
|
|
||||||
|
|
||||||
2. Active Workspaces
|
|
||||||
- active repos
|
|
||||||
- active branches
|
|
||||||
- assigned reviews
|
|
||||||
- recent deployments
|
|
||||||
|
|
||||||
3. Team Activity
|
|
||||||
- merges
|
|
||||||
- releases
|
|
||||||
- deployments
|
|
||||||
- incidents
|
|
||||||
|
|
||||||
4. CI/CD Overview
|
|
||||||
- pipeline health
|
|
||||||
- queue health
|
|
||||||
- flaky tests
|
|
||||||
- deployment status
|
|
||||||
|
|
||||||
5. Review Dashboard
|
|
||||||
- pending reviews
|
|
||||||
- high risk PRs
|
|
||||||
- stale reviews
|
|
||||||
|
|
||||||
6. Security Overview
|
|
||||||
- dependency vulnerabilities
|
|
||||||
- secret leaks
|
|
||||||
- suspicious pushes
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
REPOSITORY PAGE REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
The repository page must feel operational.
|
|
||||||
|
|
||||||
Top area should include:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
Repo Name
|
|
||||||
Production: Healthy
|
|
||||||
CI: Passing
|
|
||||||
Deployments: 3 active
|
|
||||||
Risk: Medium
|
|
||||||
```
|
|
||||||
|
|
||||||
The page should include:
|
|
||||||
|
|
||||||
- active PRs
|
|
||||||
- deployments
|
|
||||||
- environments
|
|
||||||
- repository health
|
|
||||||
- security alerts
|
|
||||||
- ownership
|
|
||||||
- recent activity
|
|
||||||
- operational timeline
|
|
||||||
- preview environments
|
|
||||||
- CI/CD overview
|
|
||||||
- deployment history
|
|
||||||
|
|
||||||
The README should not dominate the page.
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
UNIFIED OPERATIONAL TIMELINE
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Implement a unified timeline merging:
|
|
||||||
- commits
|
|
||||||
- deployments
|
|
||||||
- incidents
|
|
||||||
- rollbacks
|
|
||||||
- CI failures
|
|
||||||
- security events
|
|
||||||
- alerts
|
|
||||||
- releases
|
|
||||||
|
|
||||||
This is one of the most important features.
|
|
||||||
|
|
||||||
Users should easily answer:
|
|
||||||
|
|
||||||
> what changed before things broke?
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
PIPELINE UX REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Pipelines should be visual DAGs.
|
|
||||||
|
|
||||||
Support:
|
|
||||||
- dependency visualization
|
|
||||||
- execution timing
|
|
||||||
- bottleneck detection
|
|
||||||
- retry controls
|
|
||||||
- artifact visibility
|
|
||||||
- live execution state
|
|
||||||
- grouped logs
|
|
||||||
- semantic errors
|
|
||||||
|
|
||||||
Logs should support:
|
|
||||||
- filtering
|
|
||||||
- collapsing
|
|
||||||
- syntax highlighting
|
|
||||||
- structured parsing
|
|
||||||
- AI summarization
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
DEPLOYMENT UX REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Deployments must feel first-class.
|
|
||||||
|
|
||||||
Each environment should expose:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
Production
|
|
||||||
Healthy
|
|
||||||
v1.4.2
|
|
||||||
3 pods
|
|
||||||
0.1% errors
|
|
||||||
Last deploy 14m ago
|
|
||||||
```
|
|
||||||
|
|
||||||
Support:
|
|
||||||
- rollback
|
|
||||||
- traffic shifting
|
|
||||||
- canary visibility
|
|
||||||
- deployment timelines
|
|
||||||
- release notes
|
|
||||||
- environment logs
|
|
||||||
- health checks
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
COMMAND PALETTE REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Implement a global command palette.
|
|
||||||
|
|
||||||
Inspired by:
|
|
||||||
- VS Code
|
|
||||||
- Raycast
|
|
||||||
- Linear
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
/retry failed jobs
|
|
||||||
/deploy staging
|
|
||||||
/open logs
|
|
||||||
/review next
|
|
||||||
/show flaky tests
|
|
||||||
/open production incidents
|
|
||||||
```
|
|
||||||
|
|
||||||
Keyboard-first navigation is critical.
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
AI-ASSISTED FEATURES
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Implement optional AI-assisted operational tooling.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- failure diagnosis
|
|
||||||
- flaky test detection
|
|
||||||
- architecture summaries
|
|
||||||
- impact analysis
|
|
||||||
- deployment risk scoring
|
|
||||||
- review summaries
|
|
||||||
- onboarding explanations
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
Likely failure cause:
|
|
||||||
Database migration introduced lock contention.
|
|
||||||
```
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
OBSERVABILITY REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Integrate:
|
|
||||||
- metrics
|
|
||||||
- traces
|
|
||||||
- logs
|
|
||||||
- deployment state
|
|
||||||
- incident state
|
|
||||||
- service topology
|
|
||||||
|
|
||||||
Do not isolate observability into external systems.
|
|
||||||
|
|
||||||
Expose operational health directly in repository pages.
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
RELIABILITY REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Implement:
|
|
||||||
- durable queues
|
|
||||||
- retries
|
|
||||||
- dead-letter queues
|
|
||||||
- resumable pipelines
|
|
||||||
- distributed scheduling
|
|
||||||
- concurrency controls
|
|
||||||
- checkpointing
|
|
||||||
- idempotent operations
|
|
||||||
|
|
||||||
The platform should survive:
|
|
||||||
- runner crashes
|
|
||||||
- orchestrator crashes
|
|
||||||
- network partitions
|
|
||||||
- deployment interruptions
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
SECURITY REQUIREMENTS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Implement:
|
|
||||||
- signed artifacts
|
|
||||||
- provenance verification
|
|
||||||
- branch protections
|
|
||||||
- permission scopes
|
|
||||||
- audit logging
|
|
||||||
- secret scanning
|
|
||||||
- dependency scanning
|
|
||||||
- runner isolation
|
|
||||||
- sandboxing
|
|
||||||
|
|
||||||
Support:
|
|
||||||
- Sigstore
|
|
||||||
- Cosign
|
|
||||||
- SLSA concepts
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
UI/UX DESIGN LANGUAGE
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
The UI should feel:
|
|
||||||
- modern
|
|
||||||
- minimal
|
|
||||||
- operational
|
|
||||||
- clean
|
|
||||||
- responsive
|
|
||||||
- keyboard-first
|
|
||||||
- information dense but calm
|
|
||||||
|
|
||||||
Visual inspirations:
|
|
||||||
- Linear
|
|
||||||
- Datadog
|
|
||||||
- Grafana
|
|
||||||
- Raycast
|
|
||||||
- Arc Browser
|
|
||||||
- VS Code
|
|
||||||
- modern observability dashboards
|
|
||||||
|
|
||||||
Avoid:
|
|
||||||
- clutter
|
|
||||||
- giant tables
|
|
||||||
- excessive modal workflows
|
|
||||||
- deeply nested navigation
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
IMPLEMENTATION EXPECTATIONS
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
You should:
|
|
||||||
- think deeply about architecture
|
|
||||||
- optimize for maintainability
|
|
||||||
- optimize for extensibility
|
|
||||||
- prioritize UX heavily
|
|
||||||
- prioritize reliability heavily
|
|
||||||
- explain tradeoffs
|
|
||||||
- avoid overengineering early
|
|
||||||
- support future scalability
|
|
||||||
|
|
||||||
You should continuously ask:
|
|
||||||
|
|
||||||
- does this reduce cognitive load?
|
|
||||||
- does this improve operational awareness?
|
|
||||||
- does this improve developer flow?
|
|
||||||
- does this improve reliability?
|
|
||||||
- does this make debugging easier?
|
|
||||||
|
|
||||||
==================================================
|
|
||||||
DELIVERABLES
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
When implementing features:
|
|
||||||
|
|
||||||
Provide:
|
|
||||||
- architecture reasoning
|
|
||||||
- schema design
|
|
||||||
- API design
|
|
||||||
- event definitions
|
|
||||||
- service boundaries
|
|
||||||
- UI mockups
|
|
||||||
- workflow diagrams
|
|
||||||
- UX rationale
|
|
||||||
- security implications
|
|
||||||
- scaling considerations
|
|
||||||
- operational implications
|
|
||||||
|
|
||||||
Always optimize for:
|
|
||||||
- clarity
|
|
||||||
- reliability
|
|
||||||
- developer experience
|
|
||||||
- operational intelligence
|
|
||||||
- future extensibility
|
|
||||||
|
|
||||||
The platform should ultimately feel like:
|
|
||||||
|
|
||||||
> a unified operating system for software delivery
|
|
||||||
|
|
||||||
rather than:
|
|
||||||
|
|
||||||
> a Git repository viewer with CI attached.
|
|
||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||||
"github.com/forgeo/forgebucket/internal/domain/gitops"
|
"github.com/forgeo/forgebucket/internal/domain/gitops"
|
||||||
"github.com/forgeo/forgebucket/internal/domain/oci"
|
"github.com/forgeo/forgebucket/internal/domain/oci"
|
||||||
|
"github.com/forgeo/forgebucket/internal/domain/sshserver"
|
||||||
"github.com/forgeo/forgebucket/internal/domain/scanning"
|
"github.com/forgeo/forgebucket/internal/domain/scanning"
|
||||||
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
|
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
|
||||||
"github.com/forgeo/forgebucket/internal/domain/sbom"
|
"github.com/forgeo/forgebucket/internal/domain/sbom"
|
||||||
@@ -94,6 +95,9 @@ func main() {
|
|||||||
|
|
||||||
go observability.StartNATSWatcher(ciCtx, bus)
|
go observability.StartNATSWatcher(ciCtx, bus)
|
||||||
|
|
||||||
|
sshSrv := sshserver.New(engine, cfg)
|
||||||
|
go sshSrv.ListenAndServe(ciCtx) //nolint:errcheck
|
||||||
|
|
||||||
// Initialise artifact signing key store.
|
// Initialise artifact signing key store.
|
||||||
var keyStore *signing.KeyStore
|
var keyStore *signing.KeyStore
|
||||||
if cfg.ArtifactSigningKey != "" {
|
if cfg.ArtifactSigningKey != "" {
|
||||||
|
|||||||
+22
-6
@@ -1,21 +1,36 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18.3
|
image: postgres:18.3
|
||||||
|
container_name: fb-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: forgebucket
|
POSTGRES_DB: forgebucket
|
||||||
POSTGRES_USER: forgebucket
|
POSTGRES_USER: forgebucket
|
||||||
POSTGRES_PASSWORD: forgebucket
|
POSTGRES_PASSWORD: forgebucket
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql
|
- fb_pg_data:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U forgebucket"]
|
test: ["CMD-SHELL", "pg_isready -U forgebucket"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
nats:
|
||||||
|
image: mirror.gcr.io/nats:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["-js", "-m", "8222"]
|
||||||
|
ports:
|
||||||
|
- "4222:4222" # client connections
|
||||||
|
# "8222:8222" # monitoring HTTP
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
|
container_name: fb-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -25,11 +40,12 @@ services:
|
|||||||
DATABASE_URL: postgres://forgebucket:forgebucket@postgres:5432/forgebucket?sslmode=disable
|
DATABASE_URL: postgres://forgebucket:forgebucket@postgres:5432/forgebucket?sslmode=disable
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
- "2222:22"
|
||||||
volumes:
|
volumes:
|
||||||
- repo_data:/tmp/forgebucket/repos
|
- fb_repo_data:/tmp/forgebucket/repos
|
||||||
- oci_data:/tmp/forgebucket/oci
|
- fb_oci_data:/tmp/forgebucket/oci
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
fb_pg_data:
|
||||||
repo_data:
|
fb_repo_data:
|
||||||
oci_data:
|
fb_oci_data:
|
||||||
|
|||||||
@@ -32,5 +32,10 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
dbgate:
|
||||||
|
image: dbgate/dbgate
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { api } from '../client'
|
||||||
|
|
||||||
|
export interface InstanceConfig {
|
||||||
|
sshHost: string
|
||||||
|
sshPort: string
|
||||||
|
instanceName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceSchema = z.object({
|
||||||
|
sshHost: z.string(),
|
||||||
|
sshPort: z.string(),
|
||||||
|
instanceName: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useInstance() {
|
||||||
|
return useQuery<InstanceConfig>({
|
||||||
|
queryKey: ['instance'],
|
||||||
|
queryFn: () => api.get<InstanceConfig>('/api/v1/instance', instanceSchema),
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
|
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
|
||||||
import { useEnvironments } from '../api/queries/environments'
|
import { useEnvironments } from '../api/queries/environments'
|
||||||
|
import { useInstance } from '../api/queries/instance'
|
||||||
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
||||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||||
import { RepoAvatar } from '../ui/RepoAvatar'
|
import { RepoAvatar } from '../ui/RepoAvatar'
|
||||||
@@ -14,6 +15,7 @@ export default function RepoPage() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [showBranches, setShowBranches] = useState(false)
|
const [showBranches, setShowBranches] = useState(false)
|
||||||
const [showClone, setShowClone] = useState(false)
|
const [showClone, setShowClone] = useState(false)
|
||||||
|
const [cloneTab, setCloneTab] = useState<'https' | 'ssh'>('https')
|
||||||
const branchRef = useRef<HTMLDivElement>(null)
|
const branchRef = useRef<HTMLDivElement>(null)
|
||||||
const cloneRef = useRef<HTMLDivElement>(null)
|
const cloneRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export default function RepoPage() {
|
|||||||
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
|
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
|
||||||
const { data: branches } = useRepoBranches(owner, repoName)
|
const { data: branches } = useRepoBranches(owner, repoName)
|
||||||
const { data: environments } = useEnvironments(owner, repoName)
|
const { data: environments } = useEnvironments(owner, repoName)
|
||||||
|
const { data: instance } = useInstance()
|
||||||
const { track } = useRecentRepos()
|
const { track } = useRecentRepos()
|
||||||
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
|
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
|
||||||
|
|
||||||
@@ -42,6 +45,14 @@ export default function RepoPage() {
|
|||||||
const branch = ref || repo.defaultBranch
|
const branch = ref || repo.defaultBranch
|
||||||
const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git`
|
const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git`
|
||||||
|
|
||||||
|
const sshHost = instance?.sshHost ?? window.location.hostname
|
||||||
|
const sshPort = instance?.sshPort ?? '2222'
|
||||||
|
const sshUrl = sshPort === '22'
|
||||||
|
? `git@${sshHost}:${owner}/${repoName}.git`
|
||||||
|
: `ssh://git@${sshHost}:${sshPort}/${owner}/${repoName}.git`
|
||||||
|
|
||||||
|
const archiveBase = `/api/v1/repos/${owner}/${repoName}/archive?ref=${encodeURIComponent(branch)}`
|
||||||
|
|
||||||
function switchBranch(b: string) {
|
function switchBranch(b: string) {
|
||||||
setSearchParams({ ref: b, ...(path ? { path } : {}) })
|
setSearchParams({ ref: b, ...(path ? { path } : {}) })
|
||||||
setShowBranches(false)
|
setShowBranches(false)
|
||||||
@@ -123,17 +134,64 @@ export default function RepoPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{showClone && (
|
{showClone && (
|
||||||
<div className="absolute right-0 top-full mt-1 w-80 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-50 p-4">
|
<div className="absolute right-0 top-full mt-1 w-96 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-50 p-4 space-y-3">
|
||||||
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTP</p>
|
|
||||||
<div className="flex items-center gap-2 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2">
|
{/* Clone URL tabs */}
|
||||||
<code className="text-xs text-[var(--c-text)] flex-1 truncate">{cloneUrl}</code>
|
<div>
|
||||||
|
<div className="flex gap-1 mb-2">
|
||||||
|
{(['https', 'ssh'] as const).map(tab => (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(cloneUrl)}
|
key={tab}
|
||||||
|
onClick={() => setCloneTab(tab)}
|
||||||
|
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
|
cloneTab === tab
|
||||||
|
? 'bg-[var(--c-brand)] text-white'
|
||||||
|
: 'text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2">
|
||||||
|
<code className="text-xs text-[var(--c-text)] flex-1 truncate">
|
||||||
|
{cloneTab === 'https' ? cloneUrl : sshUrl}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(cloneTab === 'https' ? cloneUrl : sshUrl)}
|
||||||
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0"
|
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0"
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{cloneTab === 'ssh' && (
|
||||||
|
<p className="text-[10px] text-[var(--c-muted)] mt-1.5">
|
||||||
|
Requires an SSH key added to your account settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Archive download */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-1.5">Download</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ label: 'ZIP', format: 'zip' },
|
||||||
|
{ label: 'tar.gz', format: 'tar.gz' },
|
||||||
|
{ label: 'Bundle', format: 'bundle' },
|
||||||
|
].map(({ label, format }) => (
|
||||||
|
<a
|
||||||
|
key={format}
|
||||||
|
href={`${archiveBase}&format=${format}`}
|
||||||
|
download
|
||||||
|
className="flex-1 text-center px-2 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded hover:bg-[var(--c-surface-muted)] text-[var(--c-text)] transition-colors"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +199,7 @@ export default function RepoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{repo.isEmpty ? (
|
{repo.isEmpty ? (
|
||||||
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} />
|
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} sshUrl={sshUrl} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Branch selector */}
|
{/* Branch selector */}
|
||||||
@@ -231,8 +289,8 @@ function ReadmePreview({ owner, repo, ref }: { owner: string; repo: string; ref:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GettingStarted({ repoName, branch, cloneUrl }: {
|
function GettingStarted({ repoName, branch, cloneUrl, sshUrl }: {
|
||||||
repoName: string; branch: string; cloneUrl: string
|
repoName: string; branch: string; cloneUrl: string; sshUrl: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
||||||
@@ -241,10 +299,16 @@ function GettingStarted({ repoName, branch, cloneUrl }: {
|
|||||||
<p className="text-xs text-[var(--c-muted)] mt-0.5">Push your first commit to get started.</p>
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">Push your first commit to get started.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-5 space-y-6 text-sm">
|
<div className="px-5 py-5 space-y-6 text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTP</p>
|
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTPS</p>
|
||||||
<CopyBlock value={cloneUrl} />
|
<CopyBlock value={cloneUrl} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over SSH</p>
|
||||||
|
<CopyBlock value={sshUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">…or push an existing repository</p>
|
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">…or push an existing repository</p>
|
||||||
<CopyBlock value={`git remote add origin ${cloneUrl}\ngit branch -M ${branch}\ngit push -u origin ${branch}`} multiline />
|
<CopyBlock value={`git remote add origin ${cloneUrl}\ngit branch -M ${branch}\ngit push -u origin ${branch}`} multiline />
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArchiveHandler struct {
|
||||||
|
db *xorm.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArchiveHandler(db *xorm.Engine) *ArchiveHandler {
|
||||||
|
return &ArchiveHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
var archiveFormats = map[string]struct {
|
||||||
|
contentType string
|
||||||
|
ext string
|
||||||
|
}{
|
||||||
|
"zip": {"application/zip", "zip"},
|
||||||
|
"tar.gz": {"application/x-tar", "tar.gz"},
|
||||||
|
"bundle": {"application/octet-stream", "bundle"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ArchiveHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, ok := resolveRepo(h.db, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format := r.URL.Query().Get("format")
|
||||||
|
if format == "" {
|
||||||
|
format = "zip"
|
||||||
|
}
|
||||||
|
meta, allowed := archiveFormats[format]
|
||||||
|
if !allowed {
|
||||||
|
jsonError(w, "format must be zip, tar.gz, or bundle", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := r.URL.Query().Get("ref")
|
||||||
|
if ref == "" {
|
||||||
|
ref = repo.DefaultBranch
|
||||||
|
}
|
||||||
|
if ref == "" {
|
||||||
|
ref = "HEAD"
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s-%s.%s", repo.Name, ref, meta.ext)
|
||||||
|
w.Header().Set("Content-Type", meta.contentType)
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
|
||||||
|
if err := gitdomain.ArchiveStream(repo.DiskPath, ref, format, w); err != nil {
|
||||||
|
// Headers already written — can't change status code; just log and close.
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InstanceHandler struct {
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInstanceHandler(cfg *config.Config) *InstanceHandler {
|
||||||
|
return &InstanceHandler{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the public instance configuration needed by the frontend to
|
||||||
|
// construct clone URLs (SSH host, SSH port, instance name).
|
||||||
|
func (h *InstanceHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sshHost := h.sshHost(r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck
|
||||||
|
"sshHost": sshHost,
|
||||||
|
"sshPort": h.cfg.SSHPort,
|
||||||
|
"instanceName": h.cfg.InstanceName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshHost extracts the hostname from InstanceURL. Falls back to the request
|
||||||
|
// host when InstanceURL is unset (common in local development).
|
||||||
|
func (h *InstanceHandler) sshHost(r *http.Request) string {
|
||||||
|
if h.cfg.InstanceURL != "" {
|
||||||
|
if u, err := url.Parse(h.cfg.InstanceURL); err == nil && u.Hostname() != "" {
|
||||||
|
return u.Hostname()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strip port from Host header if present.
|
||||||
|
host := r.Host
|
||||||
|
if u, err := url.Parse("http://" + host); err == nil {
|
||||||
|
return u.Hostname()
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
@@ -79,6 +79,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
|||||||
ociH := handlers.NewOCIRegistryHandler(engine, ociRegistry)
|
ociH := handlers.NewOCIRegistryHandler(engine, ociRegistry)
|
||||||
scanH := handlers.NewScanningHandler(engine, scanner)
|
scanH := handlers.NewScanningHandler(engine, scanner)
|
||||||
vulnH := handlers.NewVulnScanHandler(engine, vulnScanner)
|
vulnH := handlers.NewVulnScanHandler(engine, vulnScanner)
|
||||||
|
archiveH := handlers.NewArchiveHandler(engine)
|
||||||
|
instanceH := handlers.NewInstanceHandler(cfg)
|
||||||
|
|
||||||
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||||
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
||||||
@@ -99,6 +101,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
|||||||
// ── Public ────────────────────────────────────────────────────────────
|
// ── Public ────────────────────────────────────────────────────────────
|
||||||
r.Get("/explore/repos", exploreH.Repos)
|
r.Get("/explore/repos", exploreH.Repos)
|
||||||
r.Get("/explore/users", exploreH.Users)
|
r.Get("/explore/users", exploreH.Users)
|
||||||
|
r.Get("/instance", instanceH.Get)
|
||||||
|
|
||||||
// Generates a CSRF token + cookie. SPA calls this once on load.
|
// Generates a CSRF token + cookie. SPA calls this once on load.
|
||||||
r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -177,6 +180,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
|||||||
r.With(csrf).Put("/blob", repoH.UpdateBlob)
|
r.With(csrf).Put("/blob", repoH.UpdateBlob)
|
||||||
r.Get("/commits", repoH.Commits)
|
r.Get("/commits", repoH.Commits)
|
||||||
r.Get("/branches", repoH.Branches)
|
r.Get("/branches", repoH.Branches)
|
||||||
|
r.Get("/archive", archiveH.Download)
|
||||||
r.Get("/diff", repoH.Diff)
|
r.Get("/diff", repoH.Diff)
|
||||||
r.Route("/pulls", func(r chi.Router) {
|
r.Route("/pulls", func(r chi.Router) {
|
||||||
r.Get("/", prH.List)
|
r.Get("/", prH.List)
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ type Config struct {
|
|||||||
// OCI Registry
|
// OCI Registry
|
||||||
OCIRoot string
|
OCIRoot string
|
||||||
|
|
||||||
|
// SSH server
|
||||||
|
SSHPort string // env: SSH_PORT, default "2222"
|
||||||
|
SSHHostKeyPath string // env: SSH_HOST_KEY_PATH, empty = generate ephemeral
|
||||||
|
|
||||||
// Dev
|
// Dev
|
||||||
Debug bool
|
Debug bool
|
||||||
}
|
}
|
||||||
@@ -68,6 +72,9 @@ func Load() (*Config, error) {
|
|||||||
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
|
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
|
||||||
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
|
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
|
||||||
|
|
||||||
|
cfg.SSHPort = getEnv("SSH_PORT", "2222")
|
||||||
|
cfg.SSHHostKeyPath = os.Getenv("SSH_HOST_KEY_PATH")
|
||||||
|
|
||||||
// Optional signing key
|
// Optional signing key
|
||||||
cfg.ArtifactSigningKey = os.Getenv("ARTIFACT_SIGNING_KEY")
|
cfg.ArtifactSigningKey = os.Getenv("ARTIFACT_SIGNING_KEY")
|
||||||
cfg.OCIRoot = getEnv("OCI_ROOT", filepath.Join(filepath.Dir(cfg.RepoRoot), "oci"))
|
cfg.OCIRoot = getEnv("OCI_ROOT", filepath.Join(filepath.Dir(cfg.RepoRoot), "oci"))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package git
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -467,3 +468,43 @@ func parseUnifiedDiff(raw string) []FileDiff {
|
|||||||
commit()
|
commit()
|
||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArchiveStream writes a git archive of ref in the requested format to w.
|
||||||
|
// format must be one of "zip", "tar.gz", or "bundle".
|
||||||
|
// Output is streamed directly to w without buffering.
|
||||||
|
func ArchiveStream(repoPath string, ref string, format string, w io.Writer) error {
|
||||||
|
clean := filepath.Clean(repoPath)
|
||||||
|
if repoRoot != "" {
|
||||||
|
root := repoRoot + string(filepath.Separator)
|
||||||
|
if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot {
|
||||||
|
return ErrPathTraversal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch format {
|
||||||
|
case "zip", "tar.gz":
|
||||||
|
cmd = exec.Command("git", "archive", "--format="+format, ref)
|
||||||
|
case "bundle":
|
||||||
|
cmd = exec.Command("git", "bundle", "create", "-", "--all")
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("git archive: unsupported format %q", format)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Dir = clean
|
||||||
|
cmd.Env = baseEnv
|
||||||
|
cmd.Stdout = w
|
||||||
|
|
||||||
|
var errBuf strings.Builder
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if errBuf.Len() > 0 {
|
||||||
|
return fmt.Errorf("git archive: %w: %s", err, errBuf.String())
|
||||||
|
}
|
||||||
|
return fmt.Errorf("git archive: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package sshserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lookupKey is the SSH PublicKeyCallback. It computes the MD5 fingerprint of
|
||||||
|
// the presented key (matching the format stored by the SSH key registration
|
||||||
|
// handler) and looks it up in the database.
|
||||||
|
func (s *Server) lookupKey(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||||
|
fp := fingerprintMD5(key)
|
||||||
|
|
||||||
|
var sshKey models.SSHKey
|
||||||
|
if found, _ := s.db.Where("fingerprint = ?", fp).Get(&sshKey); !found {
|
||||||
|
return nil, fmt.Errorf("unknown key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the username so the session handler can use it for permission checks.
|
||||||
|
var user models.User
|
||||||
|
if found, _ := s.db.ID(sshKey.UserID).Get(&user); !found {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ssh.Permissions{
|
||||||
|
Extensions: map[string]string{
|
||||||
|
"username": user.Username,
|
||||||
|
"user_id": fmt.Sprintf("%d", user.ID),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintMD5(pub ssh.PublicKey) string {
|
||||||
|
hash := md5.Sum(pub.Marshal())
|
||||||
|
parts := make([]string, len(hash))
|
||||||
|
for i, b := range hash {
|
||||||
|
parts[i] = fmt.Sprintf("%02x", b)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ":")
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Package sshserver implements an SSH server for git clone/push/pull operations.
|
||||||
|
// It authenticates users via their stored SSH public keys and executes
|
||||||
|
// git-upload-pack / git-receive-pack as subprocesses.
|
||||||
|
package sshserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server is the SSH git server.
|
||||||
|
type Server struct {
|
||||||
|
db *xorm.Engine
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *xorm.Engine, cfg *config.Config) *Server {
|
||||||
|
return &Server{db: db, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe binds to cfg.SSHPort, loads or generates a host key, and accepts
|
||||||
|
// connections until ctx is cancelled. Returns nil when the context is done.
|
||||||
|
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||||
|
hostKey, err := s.loadOrGenerateHostKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sshserver: host key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srvConfig := &ssh.ServerConfig{
|
||||||
|
PublicKeyCallback: s.lookupKey,
|
||||||
|
}
|
||||||
|
srvConfig.AddHostKey(hostKey)
|
||||||
|
|
||||||
|
addr := ":" + s.cfg.SSHPort
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sshserver: cannot bind %s — SSH transport disabled: %v", addr, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("sshserver: listening on %s", addr)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
ln.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
log.Printf("sshserver: accept: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go s.handleConn(conn, srvConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleConn(netConn net.Conn, srvConfig *ssh.ServerConfig) {
|
||||||
|
defer netConn.Close()
|
||||||
|
|
||||||
|
sshConn, chans, reqs, err := ssh.NewServerConn(netConn, srvConfig)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sshConn.Close()
|
||||||
|
|
||||||
|
go ssh.DiscardRequests(reqs)
|
||||||
|
|
||||||
|
username, _ := sshConn.Permissions.Extensions["username"]
|
||||||
|
|
||||||
|
for newChan := range chans {
|
||||||
|
if newChan.ChannelType() != "session" {
|
||||||
|
newChan.Reject(ssh.UnknownChannelType, "unknown channel type") //nolint:errcheck
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ch, requests, err := newChan.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go s.handleSession(ch, requests, username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOrGenerateHostKey loads the host key from SSHHostKeyPath if set,
|
||||||
|
// otherwise generates an ephemeral RSA-4096 key (lost on restart).
|
||||||
|
func (s *Server) loadOrGenerateHostKey() (ssh.Signer, error) {
|
||||||
|
if s.cfg.SSHHostKeyPath != "" {
|
||||||
|
data, err := os.ReadFile(s.cfg.SSHHostKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read host key %s: %w", s.cfg.SSHHostKeyPath, err)
|
||||||
|
}
|
||||||
|
return ssh.ParsePrivateKey(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("sshserver: SSH_HOST_KEY_PATH not set — generating ephemeral host key (host key changes on restart)")
|
||||||
|
privKey, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate host key: %w", err)
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||||
|
})
|
||||||
|
return ssh.ParsePrivateKey(keyPEM)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package sshserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleSession processes a single SSH session channel: waits for an exec
|
||||||
|
// request, dispatches to the appropriate git subcommand, then exits.
|
||||||
|
func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, username string) {
|
||||||
|
defer ch.Close()
|
||||||
|
|
||||||
|
for req := range reqs {
|
||||||
|
if req.Type != "exec" {
|
||||||
|
if req.WantReply {
|
||||||
|
req.Reply(false, nil) //nolint:errcheck
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdStr, err := parseExecPayload(req.Payload)
|
||||||
|
if err != nil {
|
||||||
|
req.Reply(false, nil) //nolint:errcheck
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Reply(true, nil) //nolint:errcheck
|
||||||
|
|
||||||
|
exitCode := s.runGitCommand(ch, username, cmdStr)
|
||||||
|
sendExitStatus(ch, uint32(exitCode))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runGitCommand parses the SSH exec command string, validates it, resolves the
|
||||||
|
// repo, checks permissions, and runs the git subprocess.
|
||||||
|
func (s *Server) runGitCommand(ch ssh.Channel, username, cmdStr string) int {
|
||||||
|
gitCmd, repoArg, err := parseGitCommand(cmdStr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(ch.Stderr(), "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve owner/repo from the path argument (e.g. "/alice/myrepo.git" or "alice/myrepo.git")
|
||||||
|
path := strings.TrimPrefix(strings.TrimSuffix(repoArg, ".git"), "/")
|
||||||
|
parts := strings.SplitN(path, "/", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
fmt.Fprintf(ch.Stderr(), "error: invalid repository path\n")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ownerName, repoName := parts[0], parts[1]
|
||||||
|
|
||||||
|
repo, err := s.resolveRepo(ownerName, repoName)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(ch.Stderr(), "error: repository not found\n")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions.
|
||||||
|
if gitCmd == "receive-pack" {
|
||||||
|
if !s.hasPermission(repo, username, "write") {
|
||||||
|
fmt.Fprintf(ch.Stderr(), "error: you do not have write access to this repository\n")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// upload-pack: public repos are accessible to all; private repos require read.
|
||||||
|
if repo.IsPrivate && !s.hasPermission(repo, username, "read") {
|
||||||
|
fmt.Fprintf(ch.Stderr(), "error: you do not have read access to this repository\n")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec the git subcommand against the bare repo path on disk.
|
||||||
|
// The disk path comes from the DB — never from user input.
|
||||||
|
cmd := exec.Command("git", gitCmd, repo.DiskPath)
|
||||||
|
cmd.Dir = filepath.Clean(repo.DiskPath)
|
||||||
|
cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
|
||||||
|
|
||||||
|
cmd.Stdin = ch
|
||||||
|
cmd.Stdout = ch
|
||||||
|
cmd.Stderr = ch.Stderr()
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
log.Printf("sshserver: git %s for %s/%s: %v", gitCmd, ownerName, repoName, err)
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return exitErr.ExitCode()
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRepo looks up a repository by owner name (user or workspace) and repo name.
|
||||||
|
func (s *Server) resolveRepo(ownerName, repoName string) (*models.Repository, error) {
|
||||||
|
var u models.User
|
||||||
|
if found, _ := s.db.Where("username = ?", ownerName).Get(&u); found {
|
||||||
|
var repo models.Repository
|
||||||
|
if found2, _ := s.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); found2 {
|
||||||
|
return &repo, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ws models.Workspace
|
||||||
|
if found, _ := s.db.Where("handle = ?", ownerName).Get(&ws); found {
|
||||||
|
var repo models.Repository
|
||||||
|
if found2, _ := s.db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); found2 {
|
||||||
|
return &repo, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPermission checks whether username has at least the required permission on repo.
|
||||||
|
func (s *Server) hasPermission(repo *models.Repository, username, required string) bool {
|
||||||
|
var u models.User
|
||||||
|
if found, _ := s.db.Where("username = ?", username).Get(&u); !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if u.ID == repo.OwnerID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var m models.RepoMember
|
||||||
|
if found, _ := s.db.Where("repo_id = ? AND user_id = ?", repo.ID, u.ID).Get(&m); !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rank := map[string]int{"read": 1, "write": 2, "admin": 3}
|
||||||
|
return rank[m.Permission] >= rank[required]
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGitCommand splits the SSH exec command string into the git subcommand
|
||||||
|
// and the repo path argument. Only upload-pack and receive-pack are permitted.
|
||||||
|
//
|
||||||
|
// Accepts both "git-upload-pack '/path'" and "git upload-pack /path" forms.
|
||||||
|
func parseGitCommand(cmdStr string) (gitCmd string, repoPath string, err error) {
|
||||||
|
cmdStr = strings.TrimSpace(cmdStr)
|
||||||
|
|
||||||
|
var candidate string
|
||||||
|
var rest string
|
||||||
|
|
||||||
|
if strings.HasPrefix(cmdStr, "git-upload-pack") {
|
||||||
|
candidate = "upload-pack"
|
||||||
|
rest = strings.TrimPrefix(cmdStr, "git-upload-pack")
|
||||||
|
} else if strings.HasPrefix(cmdStr, "git-receive-pack") {
|
||||||
|
candidate = "receive-pack"
|
||||||
|
rest = strings.TrimPrefix(cmdStr, "git-receive-pack")
|
||||||
|
} else if strings.HasPrefix(cmdStr, "git upload-pack") {
|
||||||
|
candidate = "upload-pack"
|
||||||
|
rest = strings.TrimPrefix(cmdStr, "git upload-pack")
|
||||||
|
} else if strings.HasPrefix(cmdStr, "git receive-pack") {
|
||||||
|
candidate = "receive-pack"
|
||||||
|
rest = strings.TrimPrefix(cmdStr, "git receive-pack")
|
||||||
|
} else {
|
||||||
|
return "", "", fmt.Errorf("unsupported command: only git-upload-pack and git-receive-pack are allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip surrounding whitespace and single quotes from the path argument.
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
rest = strings.Trim(rest, "'\"")
|
||||||
|
if rest == "" {
|
||||||
|
return "", "", fmt.Errorf("missing repository path argument")
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate, rest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseExecPayload decodes the SSH exec request payload: 4-byte big-endian
|
||||||
|
// length followed by the command string.
|
||||||
|
func parseExecPayload(payload []byte) (string, error) {
|
||||||
|
if len(payload) < 4 {
|
||||||
|
return "", fmt.Errorf("exec payload too short")
|
||||||
|
}
|
||||||
|
length := binary.BigEndian.Uint32(payload[:4])
|
||||||
|
if int(length) > len(payload)-4 {
|
||||||
|
return "", fmt.Errorf("exec payload length mismatch")
|
||||||
|
}
|
||||||
|
return string(payload[4 : 4+length]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendExitStatus sends an SSH exit-status channel request.
|
||||||
|
func sendExitStatus(ch ssh.Channel, code uint32) {
|
||||||
|
msg := struct{ Status uint32 }{code}
|
||||||
|
ch.SendRequest("exit-status", false, ssh.Marshal(msg)) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stderr returns the stderr stream of an SSH channel.
|
||||||
|
// The ssh.Channel type embeds io.ReadWriteCloser for stdout/stdin;
|
||||||
|
// Stderr() is defined on *ssh.channel but not the interface — use a type assertion.
|
||||||
|
func init() {
|
||||||
|
// Compile-time interface check: ssh.Channel must have Stderr() method.
|
||||||
|
var _ interface{ Stderr() io.ReadWriter } = (ssh.Channel)(nil)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user