Repository: Azure/azure-linux-extensions

Issue:
OSPatching - start time 00h error
Looks like the OSPatchingForLinux extension reports an error if you pass in a `StartTime` parameter with anything earlier than 01:00. Start times of 00:00, 00:01, 00:15 all failed, but 01:00 worked fine.

Extension version 2.3.

Repro
```
$PatchSettings = @{
    "disabled" = $false;
    "stop" = $false;
    "rebootAfterPatch" = "RebootIfNeed";
    "category" = "ImportantAndRecommended";
    "installDuration" = "02:00";
    "dayOfWeek" = "Everyday";
    "startTime" = "00:15";
    }

                    
Set-AzureRmVMExtension -Publisher Microsoft.OSTCExtensions -ExtensionType OSPatchingForLinux -ResourceGroupName $RGName -VMName $VMName -Name OSPatchingForLinux -TypeHandlerVersion 2.3 -Settings $PatchSettings -Location $Location
```

Error thrown is:
```
Set-AzureRmVMExtension : Long running operation failed with status 'Failed'.
ErrorCode: VMExtensionProvisioningError
ErrorMessage: VM has reported a failure when processing extension 'OSPatchingForLinux'. Error message: "Enable Failed. Current Configuation: category=importantandrecommended,da
yOfWeek=Everyday,distUpgradeAll=False,intervalOfWeeks=1,stop=False,disabled=False,installDuration=02:00,rebootAfterPatch=rebootifneed,oneoff=False,startTime=00:15".
StartTime: 3/5/2017 12:17:33 PM
EndTime: 3/5/2017 12:18:01 PM
OperationID: ddfe57c3-788b-47d3-ae49-c064ef4eab8f
Status: Failed
At line:13 char:1
+ Set-AzureRmVMExtension -Publisher Microsoft.OSTCExtensions -Extension ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : CloseError: (:) [Set-AzureRmVMExtension], ComputeCloudException
    + FullyQualifiedErrorId : Microsoft.Azure.Commands.Compute.SetAzureVMExtensionCommand
 ```

What's weird is that it seems to set it successfully going by the error message. And if I pull the extension settings it appears that the start time setting went through, even though the extension status is `Failed`:
```
PS C:\dev> $ext.Settings.ToString()
{
  "stop": false,
  "installDuration": "02:00",
  "dayOfWeek": "Everyday",
  "startTime": "00:15",
  "rebootAfterPatch": "RebootIfNeed",
  "disabled": false,
  "category": "ImportantAndRecommended"
}
```




Discussion:
This bug is triggered in part thanks to [this line](https://github.com/Azure/azure-linux-extensions/blob/master/OSPatching/patch/AbstractPatching.py#L253)

downloadDuration (default of 60 minutes) is subtracted from startTime to determine when to begin the download cron job. Unfortunately, the scenario for when the download needs to begin the prior day is not taken into account.

Resolving patch:
diff --git a/OSPatching/patch/AbstractPatching.py b/OSPatching/patch/AbstractPatching.py
--- a/OSPatching/patch/AbstractPatching.py
+++ b/OSPatching/patch/AbstractPatching.py
@@ -249,8 +249,15 @@ def parse_settings(self, settings):
                 msg = "The parameter \"startTime\" is empty or invalid. It defaults to 03:00."
                 self.log_and_syslog(logging.WARNING, msg)
                 start_time = "03:00"
-            self.start_time = datetime.datetime.strptime(start_time, '%H:%M')
-            self.download_time = self.start_time - datetime.timedelta(seconds=self.download_duration)
+            try:
+                start_time_dt = datetime.datetime.strptime(start_time, '%H:%M')
+                self.start_time = datetime.time(start_time_dt.hour, start_time_dt.minute)
+            except ValueError:
+                msg = "The parameter \"startTime\" is invalid. It defaults to 03:00."
+                self.log_and_syslog(logging.WARNING, msg)
+                self.start_time = datetime.time(3)
+            download_time_dt = start_time_dt - datetime.timedelta(seconds=self.download_duration)
+            self.download_time = datetime.time(download_time_dt.hour, download_time_dt.minute)
             self.current_configs["startTime"] = start_time
  
             day_of_week = settings.get("dayOfWeek")
@@ -350,12 +357,12 @@ def set_download_cron(self):
         if self.disabled:
             new_line = '\n'
         else:
+            if self.download_time > self.start_time:
+                dow = ','.join([str((day - 1) % 7) for day in self.day_of_week])
+            else:
+                dow = ','.join([str(day % 7) for day in self.day_of_week])
             hr = str(self.download_time.hour)
             minute = str(self.download_time.minute)
-            if self.download_time.day != self.start_time.day:
-                dow = ','.join([str(day - 1) for day in self.day_of_week])
-            else:
-                dow = ','.join([str(day) for day in self.day_of_week])
             new_line = ' '.join(['\n' + minute, hr, '* *', dow, 'root cd', script_dir, '&& python check.py', self.interval_of_weeks, '&& python', script_file, '-download > /dev/null 2>&1\n'])
         waagent.ReplaceFileContentsAtomic(self.crontab, '\n'.join(filter(lambda a: a and (old_line_end not in a), waagent.GetFileContents(self.crontab).split('\n'))) + new_line)
 
@@ -367,12 +374,19 @@ def set_patch_cron(self):
         if self.disabled:
             new_line = '\n'
         else:
-            hr = str(self.start_time.hour)
-            minute = str(self.start_time.minute)
-            minute_cleanup = str(self.start_time.minute + 1)
-            dow = ','.join([str(day) for day in self.day_of_week])
-            new_line = ' '.join(['\n' + minute, hr, '* *', dow, 'root cd', script_dir, '&& python check.py', self.interval_of_weeks, '&& python', script_file, '-patch >/dev/null 2>&1\n'])
-            new_line += ' '.join([minute_cleanup, hr, '* *', dow, 'root rm -f', self.stop_flag_path, '\n'])
+            start_time_dt = datetime.datetime(100, 1, 1, self.start_time.hour, self.start_time.minute)
+            start_hr = str(self.start_time.hour)
+            start_minute = str(self.start_time.minute)
+            start_dow = ','.join([str(day % 7) for day in self.day_of_week])
+            cleanup_time_dt = start_time_dt + datetime.timedelta(minutes=1)
+            cleanup_hr = str(cleanup_time_dt.hour)
+            cleanup_minute = str(cleanup_time_dt.minute)
+            if start_time_dt.day < cleanup_time_dt.day:
+                cleanup_dow = ','.join([str((day + 1) % 7) for day in self.day_of_week])
+            else:
+                cleanup_dow = ','.join([str(day % 7) for day in self.day_of_week])
+            new_line = ' '.join(['\n' + start_minute, start_hr, '* *', start_dow, 'root cd', script_dir, '&& python check.py', self.interval_of_weeks, '&& python', script_file, '-patch >/dev/null 2>&1\n'])
+            new_line += ' '.join([cleanup_minute, cleanup_hr, '* *', cleanup_dow, 'root rm -f', self.stop_flag_path, '\n'])
         waagent.ReplaceFileContentsAtomic(self.crontab, "\n".join(filter(lambda a: a and (old_line_end not in a) and (self.stop_flag_path not in a), waagent.GetFileContents(self.crontab).split('\n'))) + new_line)
 
     def restart_cron(self):


Test patch: