Skip to content

Commit c9dc61e

Browse files
authored
Add service fork command (#7)
* Add service fork command * Adapt fork implementation to agreed on interface and new fork api * Support default values via API insrtead of CLI code * Rename ForkStrategy latest to last snapshot * Fix format/type confusion in api spec and generated client.
1 parent 73901d3 commit c9dc61e

File tree

4 files changed

+557
-8
lines changed

4 files changed

+557
-8
lines changed

internal/tiger/api/types.go

Lines changed: 38 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/tiger/cmd/service.go

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func buildServiceCmd() *cobra.Command {
3737
cmd.AddCommand(buildServiceCreateCmd())
3838
cmd.AddCommand(buildServiceDeleteCmd())
3939
cmd.AddCommand(buildServiceUpdatePasswordCmd())
40+
cmd.AddCommand(buildServiceForkCmd())
4041

4142
return cmd
4243
}
@@ -984,3 +985,276 @@ func waitForServiceDeletion(client *api.ClientWithResponses, projectID string, s
984985
}
985986
}
986987
}
988+
989+
// buildServiceForkCmd creates the fork subcommand
990+
func buildServiceForkCmd() *cobra.Command {
991+
var forkServiceName string
992+
var forkNoWait bool
993+
var forkNoSetDefault bool
994+
var forkWaitTimeout time.Duration
995+
var forkNow bool
996+
var forkLastSnapshot bool
997+
var forkToTimestamp string
998+
var forkCPU int
999+
var forkMemory int
1000+
1001+
cmd := &cobra.Command{
1002+
Use: "fork [service-id]",
1003+
Short: "Fork an existing database service",
1004+
Long: `Fork an existing database service to create a new independent copy.
1005+
1006+
You must specify exactly one timing option for the fork strategy:
1007+
- --now: Fork at the current database state (creates new snapshot or uses WAL replay)
1008+
- --last-snapshot: Fork at the last existing snapshot (faster fork)
1009+
- --to-timestamp: Fork at a specific point in time (point-in-time recovery)
1010+
1011+
By default:
1012+
- Name will be auto-generated as '{source-service-name}-fork'
1013+
- CPU and memory will be inherited from the source service
1014+
- The forked service will be set as your default service
1015+
1016+
You can override any of these defaults with the corresponding flags.
1017+
1018+
Examples:
1019+
# Fork a service at the current state
1020+
tiger service fork svc-12345 --now
1021+
1022+
# Fork a service at the last snapshot
1023+
tiger service fork svc-12345 --last-snapshot
1024+
1025+
# Fork a service at a specific point in time
1026+
tiger service fork svc-12345 --to-timestamp 2025-01-15T10:30:00Z
1027+
1028+
# Fork with custom name
1029+
tiger service fork svc-12345 --now --name my-forked-db
1030+
1031+
# Fork with custom resources
1032+
tiger service fork svc-12345 --now --cpu 2000 --memory 8
1033+
1034+
# Fork without setting as default service
1035+
tiger service fork svc-12345 --now --no-set-default
1036+
1037+
# Fork without waiting for completion
1038+
tiger service fork svc-12345 --now --no-wait
1039+
1040+
# Fork with custom wait timeout
1041+
tiger service fork svc-12345 --now --wait-timeout 45m`,
1042+
RunE: func(cmd *cobra.Command, args []string) error {
1043+
// Validate timing flags first - exactly one must be specified
1044+
timingFlagsSet := 0
1045+
if forkNow {
1046+
timingFlagsSet++
1047+
}
1048+
if forkLastSnapshot {
1049+
timingFlagsSet++
1050+
}
1051+
if forkToTimestamp != "" {
1052+
timingFlagsSet++
1053+
}
1054+
1055+
if timingFlagsSet == 0 {
1056+
return fmt.Errorf("must specify --now, --last-snapshot or --to-timestamp")
1057+
}
1058+
if timingFlagsSet > 1 {
1059+
return fmt.Errorf("can only specify one of --now, --last-snapshot or --to-timestamp")
1060+
}
1061+
1062+
// Validate timestamp format early if --to-timestamp is used
1063+
if forkToTimestamp != "" {
1064+
_, err := time.Parse(time.RFC3339, forkToTimestamp)
1065+
if err != nil {
1066+
return fmt.Errorf("invalid timestamp format '%s'. Use RFC3339 format (e.g., 2025-01-15T10:30:00Z): %w", forkToTimestamp, err)
1067+
}
1068+
}
1069+
1070+
// Get config
1071+
cfg, err := config.Load()
1072+
if err != nil {
1073+
return fmt.Errorf("failed to load config: %w", err)
1074+
}
1075+
1076+
projectID := cfg.ProjectID
1077+
if projectID == "" {
1078+
return fmt.Errorf("project ID is required. Set it using login with --project-id")
1079+
}
1080+
1081+
// Determine source service ID
1082+
var serviceID string
1083+
if len(args) > 0 {
1084+
serviceID = args[0]
1085+
} else {
1086+
serviceID = cfg.ServiceID
1087+
}
1088+
1089+
if serviceID == "" {
1090+
return fmt.Errorf("service ID is required. Provide it as an argument or set a default with 'tiger config set service_id <service-id>'")
1091+
}
1092+
1093+
cmd.SilenceUsage = true
1094+
1095+
// Get API key for authentication
1096+
apiKey, err := getAPIKeyForService()
1097+
if err != nil {
1098+
return exitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w", err))
1099+
}
1100+
1101+
// Create API client
1102+
client, err := api.NewTigerClient(apiKey)
1103+
if err != nil {
1104+
return fmt.Errorf("failed to create API client: %w", err)
1105+
}
1106+
1107+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1108+
defer cancel()
1109+
1110+
// Validate custom CPU/memory if provided
1111+
cpuFlagSet := cmd.Flags().Changed("cpu")
1112+
memoryFlagSet := cmd.Flags().Changed("memory")
1113+
1114+
var finalCPU *int
1115+
var finalMemory *int
1116+
1117+
if cpuFlagSet || memoryFlagSet {
1118+
// Use provided custom values, validate against allowed combinations
1119+
validatedCPU, validatedMemory, err := util.ValidateAndNormalizeCPUMemory(forkCPU, forkMemory, cpuFlagSet, memoryFlagSet)
1120+
if err != nil {
1121+
return err
1122+
}
1123+
1124+
finalCPU = &validatedCPU
1125+
finalMemory = &validatedMemory
1126+
}
1127+
// Otherwise leave finalCPU and finalMemory as nil to inherit from source
1128+
1129+
// Determine fork strategy and target time
1130+
var forkStrategy api.ForkStrategy
1131+
var targetTime *time.Time
1132+
1133+
if forkNow {
1134+
forkStrategy = api.NOW
1135+
} else if forkLastSnapshot {
1136+
forkStrategy = api.LASTSNAPSHOT
1137+
} else if forkToTimestamp != "" {
1138+
forkStrategy = api.PITR
1139+
parsedTime, _ := time.Parse(time.RFC3339, forkToTimestamp) // Already validated above
1140+
targetTime = &parsedTime
1141+
}
1142+
1143+
// Display what we're about to do
1144+
strategyDesc := ""
1145+
switch forkStrategy {
1146+
case api.NOW:
1147+
strategyDesc = "current state"
1148+
case api.LASTSNAPSHOT:
1149+
strategyDesc = "last snapshot"
1150+
case api.PITR:
1151+
strategyDesc = fmt.Sprintf("point-in-time: %s", targetTime.Format(time.RFC3339))
1152+
}
1153+
// Prepare output message for name
1154+
displayName := forkServiceName
1155+
if !cmd.Flags().Changed("name") {
1156+
displayName = "(auto-generated)"
1157+
}
1158+
fmt.Fprintf(cmd.OutOrStdout(), "🍴 Forking service '%s' to create '%s' at %s...\n", serviceID, displayName, strategyDesc)
1159+
1160+
// Create ForkServiceCreate request
1161+
forkReq := api.ForkServiceCreate{
1162+
ForkStrategy: forkStrategy,
1163+
TargetTime: targetTime,
1164+
}
1165+
1166+
// Only set optional fields if flags were provided
1167+
if cmd.Flags().Changed("name") {
1168+
forkReq.Name = &forkServiceName
1169+
}
1170+
if finalCPU != nil {
1171+
forkReq.CpuMillis = finalCPU
1172+
}
1173+
if finalMemory != nil {
1174+
forkReq.MemoryGbs = finalMemory
1175+
}
1176+
1177+
// Make API call to fork service
1178+
forkResp, err := client.PostProjectsProjectIdServicesServiceIdForkServiceWithResponse(ctx, projectID, serviceID, forkReq)
1179+
if err != nil {
1180+
return fmt.Errorf("failed to fork service: %w", err)
1181+
}
1182+
1183+
// Handle API response
1184+
switch forkResp.StatusCode() {
1185+
case 202:
1186+
// Success - service fork accepted
1187+
if forkResp.JSON202 == nil {
1188+
fmt.Fprintln(cmd.OutOrStdout(), "✅ Fork request accepted!")
1189+
return nil
1190+
}
1191+
1192+
forkedService := *forkResp.JSON202
1193+
forkedServiceID := util.DerefStr(forkedService.ServiceId)
1194+
fmt.Fprintf(cmd.OutOrStdout(), "✅ Fork request accepted!\n")
1195+
fmt.Fprintf(cmd.OutOrStdout(), "📋 New Service ID: %s\n", forkedServiceID)
1196+
1197+
// Capture initial password from fork response and save it immediately
1198+
var initialPassword string
1199+
if forkedService.InitialPassword != nil {
1200+
initialPassword = *forkedService.InitialPassword
1201+
}
1202+
1203+
// Save password immediately after service fork
1204+
handlePasswordSaving(forkedService, initialPassword, cmd)
1205+
1206+
// Set as default service unless --no-set-default is used
1207+
if !forkNoSetDefault {
1208+
if err := setDefaultService(forkedServiceID, cmd); err != nil {
1209+
// Log warning but don't fail the command
1210+
fmt.Fprintf(cmd.OutOrStdout(), "⚠️ Warning: Failed to set service as default: %v\n", err)
1211+
}
1212+
}
1213+
1214+
// Handle wait behavior
1215+
if forkNoWait {
1216+
fmt.Fprintf(cmd.OutOrStdout(), "⏳ Service is being forked. Use 'tiger service list' to check status.\n")
1217+
return nil
1218+
}
1219+
1220+
// Wait for service to be ready with custom timeout
1221+
fmt.Fprintf(cmd.OutOrStdout(), "⏳ Waiting for fork to complete (timeout: %v)...\n", forkWaitTimeout)
1222+
if err := waitForServiceReady(client, projectID, forkedServiceID, forkWaitTimeout, cmd); err != nil {
1223+
return err
1224+
}
1225+
fmt.Fprintf(cmd.OutOrStdout(), "🎉 Service fork completed successfully!\n")
1226+
return nil
1227+
1228+
case 401:
1229+
return exitWithCode(ExitAuthenticationError, fmt.Errorf("authentication failed: invalid API key"))
1230+
case 403:
1231+
return exitWithCode(ExitPermissionDenied, fmt.Errorf("permission denied: insufficient access to fork services"))
1232+
case 404:
1233+
return exitWithCode(ExitServiceNotFound, fmt.Errorf("service '%s' not found in project '%s'", serviceID, projectID))
1234+
case 409:
1235+
return fmt.Errorf("service name '%s' already exists", forkServiceName)
1236+
case 400:
1237+
return fmt.Errorf("invalid request parameters")
1238+
default:
1239+
return fmt.Errorf("API request failed with status %d", forkResp.StatusCode())
1240+
}
1241+
},
1242+
}
1243+
1244+
// Add flags
1245+
cmd.Flags().StringVar(&forkServiceName, "name", "", "Name for the forked service (defaults to '{source-name}-fork')")
1246+
cmd.Flags().BoolVar(&forkNoWait, "no-wait", false, "Don't wait for fork operation to complete")
1247+
cmd.Flags().BoolVar(&forkNoSetDefault, "no-set-default", false, "Don't set this service as the default service")
1248+
cmd.Flags().DurationVar(&forkWaitTimeout, "wait-timeout", 30*time.Minute, "Wait timeout duration (e.g., 30m, 1h30m, 90s)")
1249+
1250+
// Timing strategy flags
1251+
cmd.Flags().BoolVar(&forkNow, "now", false, "Fork at the current database state (creates new snapshot or uses WAL replay)")
1252+
cmd.Flags().BoolVar(&forkLastSnapshot, "last-snapshot", false, "Fork at the last existing snapshot (faster)")
1253+
cmd.Flags().StringVar(&forkToTimestamp, "to-timestamp", "", "Fork at a specific point in time (RFC3339 format, e.g., 2025-01-15T10:30:00Z)")
1254+
1255+
// Resource customization flags
1256+
cmd.Flags().IntVar(&forkCPU, "cpu", 0, "CPU allocation in millicores (inherits from source if not specified)")
1257+
cmd.Flags().IntVar(&forkMemory, "memory", 0, "Memory allocation in gigabytes (inherits from source if not specified)")
1258+
1259+
return cmd
1260+
}

0 commit comments

Comments
 (0)