diff --git a/.gitignore b/.gitignore index ff5cb97..2c30504 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,6 @@ settings.json # Test results test-results/ + +# Mac Preferences File +.DS_Store diff --git a/README.md b/README.md index 97f33f1..dff5d19 100644 --- a/README.md +++ b/README.md @@ -114,11 +114,29 @@ tsql_command = "SELECT TOP 10 * FROM sys.objects" [[job.schedules]] name = "name1" -every_n_minutes = 12 +unit = "DAY" +every_n_units = 2 +schedule_time = 200000 [[job.schedules]] name = "name2" -every_n_minutes = 111 +unit = "MINUTE" +every_n_units = 111 + +[[job.schedules]] +name = "name3" +unit = "WEEK" +every_n_units = 1 +run_days = [ + "MONDAY", + "TUESDAY" + ] + +[[job.schedules]] +name = "name4" +unit = "MONTH" +every_n_units = 3 +day_of_month = 12 ``` Note, when configuring a T-SQL step in an agent job, the default database @@ -145,3 +163,16 @@ sql-deployment-tools deploy --replacement-tokens '{"SECRET_VALUE": "***"}' ```text Driver={SQL Server Native Client 11.0};Server=.;Database=SSISDB;Trusted_Connection=yes; ``` + +### Schedules TOML keys + +| key | type | required | allowable values | description | +|-----|------|----------|------------------|-------------| +| name | str | yes | any string | schedule name | +| unit | str | yes | "MINUTE" "DAY" "WEEK" "MONTH" | time unit | +| every\_n\_units | int | yes | any integer | x value in: repeats every _x unit_s | +| schedule\_time | int | no _default:0_ | integer time value HHMMSS in 24h clock e.g 0 is midnight, 70000 is 7am, 190000 is 7pm | job scheduled start time in 24h clock | +| run\_days | List[str] | when unit = "WEEK" | str values: "SUNDAY" "MONDAY" "TUESDAY" "WEDNESDAY" "THURSDAY" "FRIDAY" "SATURDAY" | days of the week that job should run | +| day\_of\_month | int | when unit = "MONTH" | any integer | day of the month to run scheduled job. 1 being first day of month | +| window_start | int _default:0_| optional when unit = "MINUTE", ignored otherwise | integer time value HHMMSS in 24h clock e.g 0 is midnight, 70000 is 7am, 190000 is 7pm | start time of window in which to run job | +| window_end | int _default:235959_| optional when unit = "MINUTE", ignored otherwise | integer time value HHMMSS in 24h clock e.g 0 is midnight, 70000 is 7am, 190000 is 7pm | end time of window in which to run job | diff --git a/src/deploy.py b/src/deploy.py index 651011e..7c0cbc3 100644 --- a/src/deploy.py +++ b/src/deploy.py @@ -68,11 +68,17 @@ def deploy_ssis( ) for job_schedule in ssis_deployment.job.schedules: - db.agent_create_job_schedule_occurs_every_n_minutes( + parameters = job_schedule.transform_for_query() + db.agent_create_job_schedule( job_name, job_schedule.name, - job_schedule.every_n_minutes, - job_schedule.start_time, + parameters.freq_type, + parameters.freq_interval, + parameters.freq_subday_type, + parameters.freq_subday_interval, + parameters.freq_recurrence_factor, + parameters.active_start_time, + parameters.active_end_time, ) if ssis_deployment.job.notification_email_address: diff --git a/src/model.py b/src/model.py index 326fd15..82bd880 100644 --- a/src/model.py +++ b/src/model.py @@ -1,4 +1,3 @@ -import datetime import typing from dataclasses import dataclass, field from enum import Enum @@ -28,6 +27,23 @@ class NotifyLevelEmail(Enum): ALWAYS = 3 +class UnitTypeFrequencyType(Enum): + MINUTE = 4 + DAY = 4 + WEEK = 8 + MONTH = 16 + + +class DayOfWeekFrequencyInterval(Enum): + SUNDAY = 1 + MONDAY = 2 + TUESDAY = 4 + WEDNESDAY = 8 + THURSDAY = 16 + FRIDAY = 32 + SATURDAY = 64 + + @dataclass class Parameter: name: str @@ -35,15 +51,76 @@ class Parameter: sensitive: bool = False +@dataclass +class ScheduleQueryParameters: + freq_type: int + freq_interval: int + freq_subday_type: int + freq_subday_interval: int + freq_recurrence_factor: int + active_start_time: int + active_end_time: int + + @dataclass_json @dataclass class Schedule: name: str - every_n_minutes: int - start_time: datetime.time = field( - default=datetime.time(hour=0, minute=0, second=0), - metadata=config(decoder=datetime.time.fromisoformat), - ) + unit: str + every_n_units: int + schedule_time: int = 0 + window_start: int = 0 + window_end: int = 235959 + run_days: typing.Optional[typing.List[str]] = None + day_of_month: typing.Optional[int] = None + + def transform_for_query(self) -> ScheduleQueryParameters: + if self.unit == "MINUTE": + return ScheduleQueryParameters( + UnitTypeFrequencyType.MINUTE.value, + 1, + 4, + self.every_n_units, + 0, + self.window_start, + self.window_end, + ) + if self.unit == "DAY": + return ScheduleQueryParameters( + UnitTypeFrequencyType.DAY.value, + self.every_n_units, + 1, + 0, + 0, + self.schedule_time, + 235959, + ) + if self.unit == "WEEK": + return ScheduleQueryParameters( + UnitTypeFrequencyType.WEEK.value, + sum([DayOfWeekFrequencyInterval[x].value for x in self.run_days]), + 1, + 0, + self.every_n_units, + self.schedule_time, + 235959, + ) + if self.unit == "MONTH": + return ScheduleQueryParameters( + UnitTypeFrequencyType.MONTH.value, + self.day_of_month, + 1, + 0, + self.every_n_units, + self.schedule_time, + 235959, + ) + + def __post_init__(self): + if self.unit == "WEEK" and not self.run_days: + raise ConfigurationError("'run_days must be provided.'") + elif self.unit == "MONTH" and not self.day_of_month: + raise ConfigurationError("'day_of_month must be provided.'") @dataclass diff --git a/src/sql/agent/create_job_schedule.sql b/src/sql/agent/create_job_schedule.sql index 21a16a5..354635e 100644 --- a/src/sql/agent/create_job_schedule.sql +++ b/src/sql/agent/create_job_schedule.sql @@ -4,13 +4,14 @@ EXEC msdb.dbo.sp_add_jobschedule @job_name = $job_name , @name = $schedule_name , @enabled = 1 -- Enabled - , @freq_type = 4 -- Daily - , @freq_interval = 1 -- Once - , @freq_subday_type = 4 -- Every N Minutes - , @freq_subday_interval = $occurs_every_n_minutes + , @freq_type = $freq_type + , @freq_interval = $freq_interval + , @freq_subday_type = $freq_subday_type + , @freq_subday_interval = $freq_subday_interval , @freq_relative_interval = 0 - , @freq_recurrence_factor = 1 - , @active_start_time = $hh_mm_ss + , @freq_recurrence_factor = $freq_recurrence_factor + , @active_start_time = $active_start_time + , @active_end_time = $active_end_time , @schedule_id = @schedule_id OUTPUT ; diff --git a/src/sql/db.py b/src/sql/db.py index a8b830b..979cf8e 100644 --- a/src/sql/db.py +++ b/src/sql/db.py @@ -1,4 +1,3 @@ -import datetime import os import pyodbc @@ -235,20 +234,30 @@ def agent_create_job_step_tsql( self._agent_reset_job_step_flow(job_name) - def agent_create_job_schedule_occurs_every_n_minutes( + def agent_create_job_schedule( self, job_name: str, schedule_name: str, - every_n_minutes: int, - start_time: datetime.time, + freq_type: int, + freq_interval: int, + freq_subday_type: int, + freq_subday_interval: int, + freq_recurrence_factor: int, + active_start_time: int, + active_end_time: int, ): self._execute_sql( query.agent_create_job_schedule, { "job_name": job_name, "schedule_name": schedule_name, - "occurs_every_n_minutes": every_n_minutes, - "hh_mm_ss": start_time.strftime("%H%M%S"), + "freq_type": freq_type, + "freq_interval": freq_interval, + "freq_subday_type": freq_subday_type, + "freq_subday_interval": freq_subday_interval, + "freq_recurrence_factor": freq_recurrence_factor, + "active_start_time": active_start_time, + "active_end_time": active_end_time, }, ) diff --git a/test/conftest.py b/test/conftest.py index c18be9c..fbefa65 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -28,8 +28,31 @@ }, ], "schedules": [ - {"name": "Winter Moon", "every_n_minutes": 30}, - {"name": "Autumn Mountain", "every_n_minutes": 1440}, + { + "name": "Winter Moon", + "unit": "DAY", + "every_n_units": 30, + "schedule_time": 200000, + }, + { + "name": "Autumn Mountain", + "unit": "MINUTE", + "every_n_units": 1440, + "window_start": 100000, + "window_end": 120000, + }, + { + "name": "Peaceful Valley", + "unit": "WEEK", + "every_n_units": 1, + "run_days": ["MONDAY", "FRIDAY"], + }, + { + "name": "Snowfall", + "unit": "MONTH", + "every_n_units": 1, + "day_of_month": 15, + }, ], }, } diff --git a/test/run_tests.sh b/test/run_tests.sh old mode 100644 new mode 100755 diff --git a/test/test_model.py b/test/test_model.py index 39b5443..efdb17d 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -1,5 +1,4 @@ import copy -import datetime from test.conftest import TEST_CONFIG import pytest @@ -9,12 +8,15 @@ from src.config import load_configuration from src.exceptions import ConfigurationError from src.model import ( + DayOfWeekFrequencyInterval, FrequencyInterval, FrequencyType, NotifyLevelEmail, Schedule, + ScheduleQueryParameters, SsisDeployment, Step, + UnitTypeFrequencyType, ) @@ -52,6 +54,98 @@ def test_FrequencyType_MONTHLY_equals_16(self): assert FrequencyType.MONTHLY.value == expected +class TestUnitTypeFrequencyType: + def test_UnitTypeFrequencyType_MINUTE_equals_4(self): + """ + Test that UnitTypeFrequencyType.MINUTE = 4 + """ + expected = 4 + + assert UnitTypeFrequencyType.MINUTE.value == expected + + def test_UnitTypeFrequencyType_DAY_equals_4(self): + """ + Test that UnitTypeFrequencyType.DAY = 4 + """ + expected = 4 + + assert UnitTypeFrequencyType.DAY.value == expected + + def test_UnitTypeFrequencyType_WEEK_equals_8(self): + """ + Test that UnitTypeFrequencyType.WEEK = 8 + """ + expected = 8 + + assert UnitTypeFrequencyType.WEEK.value == expected + + def test_UnitTypeFrequencyType_MONTH_equals_16(self): + """ + Test that UnitTypeFrequencyType.MONTH = 16 + """ + expected = 16 + + assert UnitTypeFrequencyType.MONTH.value == expected + + +class TestDayOfWeekFrequencyInterval: + def test_DayOfWeekFrequencyInterval_SUNDAY_equals_1(self): + """ + Test that DayOfWeekFrequencyInterval.SUNDAY = 1 + """ + expected = 1 + + assert DayOfWeekFrequencyInterval.SUNDAY.value == expected + + def test_DayOfWeekFrequencyInterval_MONDAY_equals_2(self): + """ + Test that DayOfWeekFrequencyInterval.MONDAY = 2 + """ + expected = 2 + + assert DayOfWeekFrequencyInterval.MONDAY.value == expected + + def test_UnitTypeFrequencyInterval_TUESDAY_equals_4(self): + """ + Test that DayOfWeekFrequencyInterval.TUESDAY = 4 + """ + expected = 4 + + assert DayOfWeekFrequencyInterval.TUESDAY.value == expected + + def test_DayOfWeekFrequencyInterval_WEDNESDAY_equals_8(self): + """ + Test that DayOfWeekFrequencyInterval.WEDNESDAY = 8 + """ + expected = 8 + + assert DayOfWeekFrequencyInterval.WEDNESDAY.value == expected + + def test_DayOfWeekFrequencyInterval_THURSDAY_equals_16(self): + """ + Test that DayOfWeekFrequencyInterval.THURSDAY = 16 + """ + expected = 16 + + assert DayOfWeekFrequencyInterval.THURSDAY.value == expected + + def test_DayOfWeekFrequencyInterval_FRIDAY_equals_32(self): + """ + Test that DayOfWeekFrequencyInterval.FRIDAY = 32 + """ + expected = 32 + + assert DayOfWeekFrequencyInterval.FRIDAY.value == expected + + def test_DayOfWeekFrequencyInterval_SATURDAY_equals_64(self): + """ + Test that DayOfWeekFrequencyInterval.SATURDAY = 64 + """ + expected = 64 + + assert DayOfWeekFrequencyInterval.SATURDAY.value == expected + + class TestFrequencyInterval: def test_FrequencyInterval_ONCE_equals_1(self): """ @@ -192,7 +286,7 @@ def test_SsisDeployment_Number_Job_Schedules_is_set_correctly(self): """ Test number of job schedules is correct. """ - expected = 2 + expected = 4 actual = load_configuration(toml.dumps(TEST_CONFIG)) assert len(actual.job.schedules) == expected @@ -201,12 +295,34 @@ def test_SsisDeployment_Job_Schedules_are_set_correctly(self): Test job schedules are set correctly. """ expected = [ - Schedule("Winter Moon", 30, datetime.time(0, 0)), - Schedule("Autumn Mountain", 1440, datetime.time(0, 0)), + Schedule("Winter Moon", "DAY", 30, 200000), + Schedule( + "Autumn Mountain", + "MINUTE", + 1440, + 0, + 100000, + 120000, + ), + Schedule("Peaceful Valley", "WEEK", 1, 0, 0, 235959, ["MONDAY", "FRIDAY"]), + Schedule("Snowfall", "MONTH", 1, 0, 0, 235959, None, 15), ] actual = load_configuration(toml.dumps(TEST_CONFIG)) assert actual.job.schedules == expected + def test_SsisDeployment_Job_Schedule_Transforms_are_correct(self): + """ + Test schedules transform into sp_schedule_job variables correctly + """ + expected = [ + ScheduleQueryParameters(4, 30, 1, 0, 0, 200000, 235959), + ScheduleQueryParameters(4, 1, 4, 1440, 0, 100000, 120000), + ScheduleQueryParameters(8, 34, 1, 0, 1, 0, 235959), + ScheduleQueryParameters(16, 15, 1, 0, 1, 0, 235959), + ] + actual = load_configuration(toml.dumps(TEST_CONFIG)) + assert [x.transform_for_query() for x in actual.job.schedules] == expected + def test_SsisDeployment_job_step_count_is_set_correctly(self): """ Test the correct number of job steps are set. @@ -219,7 +335,7 @@ def test_SsisDeployment_job_schedule_count_is_set_correctly(self): """ Test the correct number of job schedules are set. """ - expected = 2 + expected = 4 actual = len(load_configuration(toml.dumps(TEST_CONFIG)).job.schedules) assert actual == expected @@ -369,12 +485,38 @@ def test_SsisDeployment_throws_an_exception_when_schedule_minutes_is_not_an_inte """ config = copy.deepcopy(TEST_CONFIG) config["job"]["schedules"][0][ - "every_n_minutes" + "every_n_units" ] = "No mistakes, just happy little accidents" with pytest.raises(ConfigurationError): load_configuration(toml.dumps(config)) + def test_SsisDeployment_throws_an_exception_when_unit_is_day_and_run_days_missing( + self, + ): + """ + Test that if unit = 'WEEK' and run_days not present there is an error. + """ + + config = copy.deepcopy(TEST_CONFIG) + del config["job"]["schedules"][2]["run_days"] + + with pytest.raises(ConfigurationError): + load_configuration(toml.dumps(config)) + + def test_SsisDeployment_throws_exception_if_unit_is_month_and_day_of_month_missing( + self, + ): + """ + Test that if unit = 'MONTH' and day_of_month not present there is an error. + """ + + config = copy.deepcopy(TEST_CONFIG) + del config["job"]["schedules"][3]["day_of_month"] + + with pytest.raises(ConfigurationError): + load_configuration(toml.dumps(config)) + def test_SsisDeployment_throws_exception_step_type_is_ssis_tsql_command_set( self, ):