@@ -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