Skip to content

Commit 377948d

Browse files
committed
Added Rollback Functionality
1 parent d02812b commit 377948d

File tree

4 files changed

+407
-25
lines changed

4 files changed

+407
-25
lines changed

COMMANDS.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,59 @@ graft sync compose -h # Upload only (no restart)
445445

446446
---
447447

448+
## Rollback Commands
449+
450+
Manage project versioning and rollbacks. Graft automatically creates a backup of your configuration and images during every `sync`.
451+
452+
### `graft rollback`
453+
Roll back the entire project to a previous backup version.
454+
455+
```bash
456+
graft rollback
457+
```
458+
459+
**What it does:**
460+
1. Lists available backups with timestamps.
461+
2. Interactively prompts you to select a version.
462+
3. Restores `docker-compose.yml` and environment files.
463+
4. Stops current services and clearing images to prevent tag conflicts.
464+
5. Loads and re-tags compressed images from the backup.
465+
6. Restarts all services using the restored versions with `--pull never`.
466+
467+
---
468+
469+
### `graft rollback service <name>`
470+
Roll back only a specific service to a previous version.
471+
472+
```bash
473+
graft rollback service backend
474+
```
475+
476+
**What it does:**
477+
1. Lists available backups.
478+
2. Extracts the specific service configuration from the selected backup.
479+
3. Updates the remote `docker-compose.yml` for only that service.
480+
4. Stops the service and restores its specific image.
481+
5. Restarts the targeted service without affecting others using `--pull never`.
482+
483+
---
484+
485+
### `graft rollback config`
486+
Configure how many rollback versions Graft should keep on the server.
487+
488+
```bash
489+
graft rollback config
490+
```
491+
492+
**Interactive Options:**
493+
- **Change Limit**: Set how many historical versions to retain (default: 3).
494+
- **Disable Rollbacks**: Set limit to `0` to stop creating backups.
495+
- **Remove Config**: Delete the rollback configuration from the project.
496+
497+
**Note:** Changes are synced to both your local `.graft/project.json` and the remote `graft-hook` configuration.
498+
499+
---
500+
448501
## DNS Mapping Commands
449502

450503
### `graft map`

README.md

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ Built for the solo developer rotating between cloud free tiers and the small tea
3030
**Graft's approach:**
3131
```bash
3232
# Local development
33-
docker compose logs backend
33+
docker compose up -d --pull always
3434

3535
# Production with Graft (same commands)
36-
graft logs backend
36+
graft up -d --pull always
3737
```
3838

3939
Same workflow. Different server. That's it.
@@ -93,6 +93,16 @@ graft exec backend cat /app/config.yml # Read files
9393
graft run alpine echo "hello" # Quick throwaway commands
9494
```
9595

96+
**Automatic Dns Mapping for Cloudflare based DNSs:**
97+
```bash
98+
graft map #Automatically detects domains by service and sets DNS
99+
```
100+
**Easy Rollback to previous deployments:**
101+
```bash
102+
graft rollback #Display previous deployments and allow you to rollback to any of them
103+
graft rollback config #Set up how many versions to keep
104+
```
105+
96106
**Important caveat:** Interactive sessions (like `graft exec -it backend bash`) don't work due to SSH-in-SSH limitations. For that, use `graft -sh` to drop into a proper SSH session first, then run your Docker commands there.
97107

98108
**All your muscle memory still works.** If you know Docker Compose, you know Graft. The only difference is your services are running on a server in some datacenter instead of melting your laptop's CPU.
@@ -180,6 +190,8 @@ graft sync
180190
graft ps # Check status
181191
graft logs backend # View logs
182192
graft restart frontend # Restart service
193+
graft map #automatically updates cloudflare dns records
194+
graft rollback #roll back to previous versions
183195
```
184196

185197
**That's it.** Your project is running on the server, managed via familiar commands.
@@ -231,18 +243,11 @@ graft exec backend cat /app/log.txt # One-liner commands work
231243

232244
**Not for:**
233245
- Multi-region deployments
234-
- Auto-scaling based on load (yet)
235-
- Complex microservice meshes
236-
- Teams that need Kubernetes features
237-
238-
If you need Kubernetes, use Kubernetes. Graft is for everyone else who just wants to deploy Docker Compose projects and get back to building features.
239-
246+
- Multi-server deployments
240247
---
241248

242249
## 🏷️ What Makes This Different
243250

244-
**vs Kubernetes:** Simpler. Uses Docker Compose instead of new abstractions. For when you need to deploy services, not manage a cluster.
245-
246251
**vs Dokku/CapRover:** No web UI, pure CLI. More flexible deployment modes. Better for managing multiple projects across multiple servers.
247252

248253
**vs Railway/Render:** Self-hosted. No vendor lock-in. Works anywhere you have SSH. No monthly bills that scale with your success.
@@ -255,8 +260,6 @@ If you need Kubernetes, use Kubernetes. Graft is for everyone else who just want
255260

256261
Planned features:
257262
- Dev/prod environment separation
258-
- Database backup automation (with S3/R2) [Added-Experimental]
259-
- Rollback mechanism
260263
- Slack/Discord notifications
261264
- Health checks and monitoring
262265

cmd/graft/main.go

Lines changed: 188 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func main() {
3030
if len(args) > 0 {
3131
arg := args[0]
3232
if arg == "-v" || arg == "--version" {
33-
fmt.Println("v2.1.3")
33+
fmt.Println("v2.2.0")
3434
return
3535
}
3636
if arg == "--help" {
@@ -140,7 +140,17 @@ func main() {
140140
runSync(args[1:])
141141
}
142142
case "rollback":
143-
runRollback()
143+
if len(args) > 1 && args[1] == "config" {
144+
runRollbackConfig()
145+
} else if len(args) > 1 && args[1] == "service" {
146+
if len(args) < 3 {
147+
fmt.Println("Usage: graft rollback service <service-name>")
148+
return
149+
}
150+
runServiceRollback(args[2])
151+
} else {
152+
runRollback()
153+
}
144154
case "registry":
145155
if len(args) < 2 {
146156
fmt.Println("Usage: graft registry [ls|add|del]")
@@ -233,6 +243,8 @@ func printUsage() {
233243
fmt.Println(" db/redis <name> init Initialize shared infrastructure")
234244
fmt.Println(" sync [service] [-h] Deploy project to server")
235245
fmt.Println(" rollback Restore project to a previous backup")
246+
fmt.Println(" rollback service <name> Restore specific service from a backup")
247+
fmt.Println(" rollback config Configure rollback versions to keep")
236248
fmt.Println(" logs <service> Stream service logs")
237249
fmt.Println(" mode Change project deployment mode")
238250
fmt.Println(" map Map all service domains to Cloudflare DNS")
@@ -1889,7 +1901,7 @@ func runRollback() {
18891901
}
18901902

18911903
if meta.RollbackBackups <= 0 {
1892-
fmt.Println("❌ Rollback is not configured for this project. Setup rollbacks during 'graft init' or update your project configuration.")
1904+
fmt.Println("❌ Rollback is not configured for this project. Setup rollbacks during 'graft init' or update your project configuration with 'graft rollback config'.")
18931905
return
18941906
}
18951907

@@ -1954,6 +1966,179 @@ func runRollback() {
19541966
}
19551967
}
19561968

1969+
func runRollbackConfig() {
1970+
meta, err := config.LoadProjectMetadata()
1971+
if err != nil {
1972+
fmt.Println("Error: Could not load project metadata. Run 'graft init' first.")
1973+
return
1974+
}
1975+
1976+
cfg, err := config.LoadConfig()
1977+
if err != nil {
1978+
fmt.Println("Error: No config found.")
1979+
return
1980+
}
1981+
1982+
fmt.Printf("🔄 Rollback Configuration for project: %s\n", meta.Name)
1983+
fmt.Printf("Current versions to keep: %d\n", meta.RollbackBackups)
1984+
1985+
reader := bufio.NewReader(os.Stdin)
1986+
fmt.Print("\nDo you want to change or remove rollback configuration? (y: change, n: remove, enter: skip): ")
1987+
input, _ := reader.ReadString('\n')
1988+
input = strings.ToLower(strings.TrimSpace(input))
1989+
1990+
var newVersionToKeep int
1991+
var action string
1992+
1993+
if input == "y" || input == "yes" {
1994+
fmt.Print("Enter the new number of versions to keep: ")
1995+
rollInput, _ := reader.ReadString('\n')
1996+
newVersionToKeep, err = strconv.Atoi(strings.TrimSpace(rollInput))
1997+
if err != nil || newVersionToKeep < 0 {
1998+
fmt.Println("❌ Invalid input. Number must be 0 or greater.")
1999+
return
2000+
}
2001+
action = "updated"
2002+
} else if input == "n" || input == "no" {
2003+
newVersionToKeep = 0
2004+
action = "removed"
2005+
} else {
2006+
fmt.Println("⏭️ Skipping configuration.")
2007+
return
2008+
}
2009+
2010+
fmt.Printf("🔍 Connecting to %s (%s) to update remote configuration...\n", cfg.Server.RegistryName, cfg.Server.Host)
2011+
client, err := ssh.NewClient(cfg.Server.Host, cfg.Server.Port, cfg.Server.User, cfg.Server.KeyPath)
2012+
if err != nil {
2013+
fmt.Printf("Error: %v\n", err)
2014+
return
2015+
}
2016+
defer client.Close()
2017+
2018+
// 1. Update remote projects registry
2019+
tmpFile := filepath.Join(os.TempDir(), "remote_projects.json")
2020+
remoteProjects := make(map[string]interface{})
2021+
if err := client.DownloadFile(config.RemoteProjectsPath, tmpFile); err == nil {
2022+
data, _ := os.ReadFile(tmpFile)
2023+
json.Unmarshal(data, &remoteProjects)
2024+
os.Remove(tmpFile)
2025+
}
2026+
2027+
if entry, exists := remoteProjects[meta.Name]; exists {
2028+
if m, ok := entry.(map[string]interface{}); ok {
2029+
if newVersionToKeep > 0 {
2030+
m["rollback_backups"] = newVersionToKeep
2031+
} else {
2032+
delete(m, "rollback_backups")
2033+
}
2034+
remoteProjects[meta.Name] = m
2035+
}
2036+
} else {
2037+
// If it doesn't exist for some reason, create it
2038+
remoteProjects[meta.Name] = map[string]interface{}{
2039+
"path": meta.RemotePath,
2040+
"rollback_backups": newVersionToKeep,
2041+
}
2042+
}
2043+
2044+
data, _ := json.MarshalIndent(remoteProjects, "", " ")
2045+
os.WriteFile(tmpFile, data, 0644)
2046+
if err := client.UploadFile(tmpFile, config.RemoteProjectsPath); err != nil {
2047+
fmt.Printf("❌ Failed to update remote registry: %v\n", err)
2048+
return
2049+
}
2050+
os.Remove(tmpFile)
2051+
2052+
// 2. Update local metadata
2053+
meta.RollbackBackups = newVersionToKeep
2054+
if err := config.SaveProjectMetadata(meta); err != nil {
2055+
fmt.Printf("⚠️ Warning: Could not save local metadata: %v\n", err)
2056+
}
2057+
2058+
fmt.Printf("✅ Rollback configuration %s successfully.\n", action)
2059+
2060+
// 3. Restart Webhook
2061+
fmt.Println("🔄 Restarting graft-hook to apply changes...")
2062+
if err := client.RunCommand("cd /opt/graft/webhook && sudo docker compose down && sudo docker compose up -d", os.Stdout, os.Stderr); err != nil {
2063+
fmt.Printf("⚠️ Warning: Failed to restart graft-hook: %v\n", err)
2064+
} else {
2065+
fmt.Println("✅ graft-hook restarted successfully.")
2066+
}
2067+
}
2068+
2069+
func runServiceRollback(serviceName string) {
2070+
meta, err := config.LoadProjectMetadata()
2071+
if err != nil {
2072+
fmt.Println("Error: Could not load project metadata. Run 'graft init' first.")
2073+
return
2074+
}
2075+
2076+
if meta.RollbackBackups <= 0 {
2077+
fmt.Println("❌ Rollback is not configured for this project. Setup rollbacks during 'graft init' or update your project configuration with 'graft rollback config'.")
2078+
return
2079+
}
2080+
2081+
cfg, err := config.LoadConfig()
2082+
if err != nil {
2083+
fmt.Println("Error: No config found.")
2084+
return
2085+
}
2086+
2087+
fmt.Printf("🔍 Connecting to %s (%s)...\n", cfg.Server.RegistryName, cfg.Server.Host)
2088+
client, err := ssh.NewClient(cfg.Server.Host, cfg.Server.Port, cfg.Server.User, cfg.Server.KeyPath)
2089+
if err != nil {
2090+
fmt.Printf("Error: %v\n", err)
2091+
return
2092+
}
2093+
defer client.Close()
2094+
2095+
backupBase := fmt.Sprintf("/opt/graft/backup/%s", meta.Name)
2096+
// List directories in backup path, newest first
2097+
out, err := client.GetCommandOutput(fmt.Sprintf("sudo ls -1dt %s/* 2>/dev/null", backupBase))
2098+
if err != nil || strings.TrimSpace(out) == "" {
2099+
fmt.Println("❌ No backups found on server.")
2100+
return
2101+
}
2102+
2103+
backups := strings.Split(strings.TrimSpace(out), "\n")
2104+
fmt.Println("\n📦 Available Backups (Newest First):")
2105+
var choices []string
2106+
for i, p := range backups {
2107+
timestamp := filepath.Base(p)
2108+
formatted := formatTimestamp(timestamp)
2109+
fmt.Printf(" [%d] %s\n", i+1, formatted)
2110+
choices = append(choices, timestamp)
2111+
}
2112+
2113+
fmt.Printf("\nSelect a version to rollback service '%s' to [1-%d] (or enter to cancel): ", serviceName, len(choices))
2114+
reader := bufio.NewReader(os.Stdin)
2115+
input, _ := reader.ReadString('\n')
2116+
input = strings.TrimSpace(input)
2117+
if input == "" {
2118+
fmt.Println("❌ Rollback cancelled.")
2119+
return
2120+
}
2121+
2122+
choice, err := strconv.Atoi(input)
2123+
if err != nil || choice < 1 || choice > len(choices) {
2124+
fmt.Println("❌ Invalid selection.")
2125+
return
2126+
}
2127+
2128+
selected := choices[choice-1]
2129+
2130+
p := &deploy.Project{
2131+
Name: meta.Name,
2132+
RollbackBackups: meta.RollbackBackups,
2133+
}
2134+
2135+
if err := deploy.RestoreServiceRollback(client, p, selected, serviceName, os.Stdout, os.Stderr); err != nil {
2136+
fmt.Printf("❌ Rollback failed: %v\n", err)
2137+
} else {
2138+
fmt.Printf("\n✅ Service '%s' rollback successful!\n", serviceName)
2139+
}
2140+
}
2141+
19572142
func formatTimestamp(ts string) string {
19582143
if len(ts) != 14 {
19592144
return ts

0 commit comments

Comments
 (0)