From 428d34964dc81dc47f7b5d5dd1c9f16e13322b1c Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 11 Apr 2026 18:41:16 -0300 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#250:=20Convert=20*/N=20?= =?UTF-8?q?to=200/N=20in=20=5Fcrontab=5Fto=5Faws=20for=20EventBridge=20com?= =?UTF-8?q?patibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cloud/aws/schedule.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/dotflow/cloud/aws/schedule.py b/dotflow/cloud/aws/schedule.py index 4c4b07bd..c43a5241 100644 --- a/dotflow/cloud/aws/schedule.py +++ b/dotflow/cloud/aws/schedule.py @@ -58,9 +58,22 @@ def _crontab_to_aws(expression: str) -> str: minute, hour, dom, month, dow = parts - if dow != "*" and dom == "*": + minute = _fix_step(minute) + hour = _fix_step(hour) + dom = _fix_step(dom) + month = _fix_step(month) + dow = _fix_step(dow) + + if dow != "*" and dow != "?" and dom == "*": dom = "?" else: dow = "?" return f"cron({minute} {hour} {dom} {month} {dow} *)" + + +def _fix_step(field: str) -> str: + """Convert */N to 0/N for AWS EventBridge compatibility.""" + if field.startswith("*/"): + return "0/" + field[2:] + return field From 1b067b77d75f425120098bb48ffcde560a3acfb7 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 11 Apr 2026 18:41:29 -0300 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#250:=20Update=20?= =?UTF-8?q?schedule=20tests=20for=200/N=20conversion=20and=20add=20step=20?= =?UTF-8?q?edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/cloud/aws/test_schedule.py | 56 ++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/tests/cloud/aws/test_schedule.py b/tests/cloud/aws/test_schedule.py index 7200c820..9e4087be 100644 --- a/tests/cloud/aws/test_schedule.py +++ b/tests/cloud/aws/test_schedule.py @@ -4,40 +4,91 @@ from pathlib import Path from tempfile import NamedTemporaryFile -from dotflow.cloud.aws.schedule import AWSSchedule +from dotflow.cloud.aws.schedule import AWSSchedule, _fix_step + + +class TestFixStep(unittest.TestCase): + def test_star_slash_converts_to_zero_slash(self): + self.assertEqual(_fix_step("*/5"), "0/5") + + def test_star_slash_large_number(self): + self.assertEqual(_fix_step("*/30"), "0/30") + + def test_plain_star_unchanged(self): + self.assertEqual(_fix_step("*"), "*") + + def test_fixed_value_unchanged(self): + self.assertEqual(_fix_step("30"), "30") + + def test_range_unchanged(self): + self.assertEqual(_fix_step("1-5"), "1-5") + + def test_explicit_start_step_unchanged(self): + self.assertEqual(_fix_step("0/5"), "0/5") + + def test_question_mark_unchanged(self): + self.assertEqual(_fix_step("?"), "?") class TestAWSScheduleConvert(unittest.TestCase): def test_crontab_every_hour(self): result = AWSSchedule.convert("0 * * * *") + self.assertEqual(result, "cron(0 * * * ? *)") def test_crontab_every_5_minutes(self): result = AWSSchedule.convert("*/5 * * * *") - self.assertEqual(result, "cron(*/5 * * * ? *)") + + self.assertEqual(result, "cron(0/5 * * * ? *)") + + def test_crontab_every_30_minutes(self): + result = AWSSchedule.convert("*/30 * * * *") + + self.assertEqual(result, "cron(0/30 * * * ? *)") + + def test_crontab_step_in_hours(self): + result = AWSSchedule.convert("0 */6 * * *") + + self.assertEqual(result, "cron(0 0/6 * * ? *)") + + def test_crontab_step_in_minutes_and_hours(self): + result = AWSSchedule.convert("*/10 */2 * * *") + + self.assertEqual(result, "cron(0/10 0/2 * * ? *)") + + def test_crontab_no_step_unchanged(self): + result = AWSSchedule.convert("30 2 * * *") + + self.assertEqual(result, "cron(30 2 * * ? *)") def test_crontab_specific_day_of_week(self): result = AWSSchedule.convert("0 6 * * 1") + self.assertEqual(result, "cron(0 6 ? * 1 *)") def test_crontab_specific_day_of_month(self): result = AWSSchedule.convert("0 0 1 * *") + self.assertEqual(result, "cron(0 0 1 * ? *)") def test_rate_passthrough(self): result = AWSSchedule.convert("rate(6 hours)") + self.assertEqual(result, "rate(6 hours)") def test_cron_aws_passthrough(self): result = AWSSchedule.convert("cron(0 * * * ? *)") + self.assertEqual(result, "cron(0 * * * ? *)") def test_invalid_returns_as_is(self): result = AWSSchedule.convert("not a cron") + self.assertEqual(result, "not a cron") def test_strips_whitespace(self): result = AWSSchedule.convert(" rate(1 day) ") + self.assertEqual(result, "rate(1 day)") @@ -63,6 +114,7 @@ def test_reads_schedule_from_sam_template(self): def test_returns_none_for_missing_file(self): result = AWSSchedule.read_from_template(Path("nonexistent.yaml")) + self.assertIsNone(result) def test_returns_none_for_no_schedule(self): From 87780cf01598bc1cf7c98609371e139a58719556 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 11 Apr 2026 18:46:28 -0300 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=98=20DOCS-#250:=20Add=20PR=20#251?= =?UTF-8?q?=20to=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/nav/development/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/nav/development/release-notes.md b/docs/nav/development/release-notes.md index e56b8b45..09194620 100644 --- a/docs/nav/development/release-notes.md +++ b/docs/nav/development/release-notes.md @@ -4,6 +4,7 @@ - [📦 PyPI - Build 0.15.0](https://github.com/dotflow-io/dotflow/releases/tag/v0.15.0) - [🪲 Bug: _serialize_context crashes when storage is a list of non-Context objects](https://github.com/dotflow-io/dotflow/pull/249) +- [🪲 Bug: _crontab_to_aws generates invalid EventBridge cron — */N not supported](https://github.com/dotflow-io/dotflow/pull/251) - [⚙️ Feature: Add Alibaba Cloud Function Compute deployer](https://github.com/dotflow-io/dotflow/pull/238) - [⚙️ Feature: Implement Server provider for remote API communication](https://github.com/dotflow-io/dotflow/pull/240) - [⚙️ Feature: dotflow deploy CLI — cross-cloud infrastructure generation](https://github.com/dotflow-io/dotflow/pull/183)