From ed92718b63babfc99ed95f61f1894f64bffa31db Mon Sep 17 00:00:00 2001 From: Abdelhadi Sabani Date: Fri, 22 May 2026 17:11:12 +0100 Subject: [PATCH 1/2] feat(reconciler): cronix adopt for crontab backend (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cronix adopt .` claims a pre-existing scheduler entry that already invokes `cronix trigger` and applies the cronix ownership markers without re-creating the entry. The original line is preserved byte-for-byte; only the cronix:owned comment is added after it. This PR ships the design + the crontab implementation. Per-backend follow-ups for systemd-timer (#40), kubernetes (#41), aws-scheduler (#42), and vercel (#43) implement the same Adopter interface against their native scheduler models. What ships in this change: - go/internal/backends/backend.go: new optional Adopter interface, AdoptOpts, AdoptResult. Optional rather than baseline so backends can implement it incrementally without breaking interface compliance. CLI handles backends that don't implement it with a clear error pointing at the per-backend tracking issue. - go/internal/backends/crontab/adopt.go: reference implementation. Strict matching — a line is a candidate only if its command tail is exactly trigger .. Multi-schedule jobs require a candidate per schedule with matching 5-field cron; partial coverage is reported as divergent rather than partially adopted. Insertion is reverse-order so line indices stay valid during the rewrite. - go/internal/backends/crontab/adopt_test.go: 8 tests covering single-schedule success, dry-run no-modify, divergent schedule no-change, already-managed no-op, no-candidate, multi-schedule all-present, partial multi-schedule divergence, and an interface-compliance compile check. - go/internal/cli/commands/adopt.go: cronix adopt CLI. Argument is . mirroring cronix trigger. Reuses the existing bindBackendFlags + buildBackend so all backend-specific flags surface uniformly. Output format mirrors the validate command (table or json). Exit codes: 0 success/already-managed/dry-run- found, 6 divergent, 7 no candidate. - go/internal/cli/commands/root.go: wires the adopt command into the root. - docs/src/content/docs/operations/migrating-from-crontab.md: new operator-facing page. Four-command migration walkthrough, an honest explanation of what adopt accepts vs rejects, exit codes, multi-schedule semantics, forward pointer to the other backends' follow-up issues. - docs/astro.config.mjs: registers the new page in the Operations sidebar between observability and the future per-backend pages. Test: go test ./internal/backends/crontab/... -run Adopt -v === RUN TestAdopt_SingleScheduleSuccess --- PASS: TestAdopt_SingleScheduleSuccess === RUN TestAdopt_DryRunDoesNotModify --- PASS: TestAdopt_DryRunDoesNotModify === RUN TestAdopt_DivergentScheduleNoChange --- PASS: TestAdopt_DivergentScheduleNoChange === RUN TestAdopt_AlreadyManagedIsNoOp --- PASS: TestAdopt_AlreadyManagedIsNoOp === RUN TestAdopt_NoCandidateReportsNotFound --- PASS: TestAdopt_NoCandidateReportsNotFound === RUN TestAdopt_MultiScheduleAllPresent --- PASS: TestAdopt_MultiScheduleAllPresent === RUN TestAdopt_PartialMultiScheduleIsDivergent --- PASS: TestAdopt_PartialMultiScheduleIsDivergent === RUN TestAdopt_AdoptInterfaceCompliance --- PASS: TestAdopt_AdoptInterfaceCompliance End-to-end smoke against a tempfile crontab + tempfile manifest: $ cronix adopt billing.ping --backend crontab --crontab-path $TMP \ --trigger-bin /usr/local/bin/cronix --manifest $MANIFEST --dry-run WOULD-ADOPT billing.ping (1 entry — dry-run, no changes) $ cronix adopt billing.ping --backend crontab --crontab-path $TMP \ --trigger-bin /usr/local/bin/cronix --manifest $MANIFEST ADOPTED billing.ping (1 entry now under management) $ cat $TMP 0 * * * * /usr/local/bin/cronix trigger billing.ping # cronix:owned app=billing job=ping hash=ff6d127ae161a72f idx=0 Signed-off-by: Abdelhadi Sabani --- docs/astro.config.mjs | 1 + .../docs/operations/migrating-from-crontab.md | 116 ++++++++ go/internal/backends/backend.go | 55 ++++ go/internal/backends/crontab/adopt.go | 254 ++++++++++++++++++ go/internal/backends/crontab/adopt_test.go | 194 +++++++++++++ go/internal/cli/commands/adopt.go | 180 +++++++++++++ go/internal/cli/commands/root.go | 1 + 7 files changed, 801 insertions(+) create mode 100644 docs/src/content/docs/operations/migrating-from-crontab.md create mode 100644 go/internal/backends/crontab/adopt.go create mode 100644 go/internal/backends/crontab/adopt_test.go create mode 100644 go/internal/cli/commands/adopt.go diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index d8c5c70..572af0a 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -57,6 +57,7 @@ export default defineConfig({ items: [ { label: "Production runbook", slug: "operations/runbook" }, { label: "Observability", slug: "operations/observability" }, + { label: "Migrating from hand-edited crontab", slug: "operations/migrating-from-crontab" }, ], }, { diff --git a/docs/src/content/docs/operations/migrating-from-crontab.md b/docs/src/content/docs/operations/migrating-from-crontab.md new file mode 100644 index 0000000..e38d77a --- /dev/null +++ b/docs/src/content/docs/operations/migrating-from-crontab.md @@ -0,0 +1,116 @@ +--- +title: Migrating from hand-edited crontab +description: Walk an existing crontab full of cronix trigger lines into cronix management without re-creating entries. +--- + +If you've been using `cronix trigger` from a hand-edited crontab — maybe because you started before the reconciler was ready, maybe because you wanted to roll out trigger-side benefits (HMAC, retries, locks) before committing to declarative management — the `cronix adopt` command brings those lines under reconciliation without disrupting their firing cadence. + +**What "adopt" means:** find the existing crontab line that already invokes `cronix trigger .` and add the cronix ownership marker after it. The original line is preserved byte-for-byte; only a comment line is added. The very next `cron(8)` fire is unaffected. + +**What "adopt" does NOT do:** it does not change schedules, does not rewrite commands, and never deletes a line. If your existing line disagrees with what the manifest says, adopt refuses to modify anything and prints the divergences for you to resolve. + +## The full migration in four commands + +```sh +# 1. Verify your manifest is reachable + valid +cronix validate https://billing.example.com/.well-known/cron-manifest \ + --secret-ref env:CRON_SECRET + +# 2. Preview what adopt would do — no changes +cronix adopt billing.reconcile-payments \ + --backend crontab \ + --crontab-path /etc/crontab \ + --trigger-bin /usr/local/bin/cronix \ + --manifest https://billing.example.com/.well-known/cron-manifest \ + --secret-ref env:CRON_SECRET \ + --dry-run + +# 3. Adopt for real (writes ownership marker) +cronix adopt billing.reconcile-payments \ + --backend crontab \ + --crontab-path /etc/crontab \ + --trigger-bin /usr/local/bin/cronix \ + --manifest https://billing.example.com/.well-known/cron-manifest \ + --secret-ref env:CRON_SECRET + +# 4. From this point on, just use `cronix apply` normally +cronix apply --backend crontab \ + --crontab-path /etc/crontab \ + --trigger-bin /usr/local/bin/cronix \ + --manifest https://billing.example.com/.well-known/cron-manifest \ + --secret-ref env:CRON_SECRET +``` + +Repeat steps 2–3 for every `(app, job)` pair you have. There's no batch mode in v1.0.0-rc.1; future versions will support adopting an entire manifest in one call. + +## What adopt accepts + +A crontab line is a **candidate** if its command tail is **exactly**: + +``` + trigger . +``` + +with the `` matching the `--trigger-bin` flag you passed. Anything else — a wrapper script, extra arguments, a different binary path — is not adopted. The strictness is deliberate: cronix doesn't want to guess your intent at adoption time. + +If your line looks like: + +```cron +0 * * * * /opt/scripts/run-cronix.sh billing reconcile-payments +``` + +…adopt won't claim it. Two paths forward: + +1. **Rewrite the line yourself** to invoke `cronix trigger` directly, then run adopt. +2. **Let `cronix apply` overwrite it.** First `cronix prune` the existing line (or delete it manually), then `cronix apply` to create the managed version. This is non-atomic — there's a brief window where the schedule doesn't fire — but it's the only path if you want to keep the wrapper behavior in a script vs. inline. + +## What adopt rejects (divergence) + +Adopt refuses to claim a line whose schedule (5-field cron) doesn't match the manifest. Example: + +``` +crontab: */5 * * * * /usr/local/bin/cronix trigger billing.ping +manifest: @hourly (i.e. 0 * * * *) +``` + +Adopt prints: + +``` +DIVERGED billing.ping (no action taken) + ! schedules[0] ("@hourly" → "0 * * * *"): no candidate crontab line with this 5-field cron + ! line 1 (cron "*/5 * * * *") invokes /usr/local/bin/cronix trigger billing.ping but does not match any manifest schedule +``` + +Resolve by editing either side so they agree, then re-run adopt. Or if you want the manifest to win unconditionally, run `cronix apply` — it will replace the line. + +## What adopt skips + +A `(app, job)` that's **already cronix-managed** (has the `# cronix:owned` marker) returns `ALREADY-MANAGED` with no action. Idempotent — safe to run adopt repeatedly in CI. + +A `(app, job)` with **no candidate line at all** returns `NOT-FOUND` and exits 7. You probably want `cronix apply` (which creates) rather than adopt (which claims existing). + +## Multi-schedule jobs + +A manifest job with multiple schedules requires a candidate line **per schedule**. All schedules must be present in the crontab; partial coverage is reported as divergent. The mapping is by 5-field cron equality, so the order of lines in your crontab doesn't matter — adopt finds the right line for each schedule. + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | Adopted, already-managed, or dry-run found candidates | +| 6 | Diverged — manifest and backend disagree, no action taken | +| 7 | No candidate entry found on the backend | + +CI scripts can `if cronix adopt ... ; then ...` to handle each case. + +## Beyond crontab + +`cronix adopt` ships for the `crontab` backend in v1.0.0-rc.1. The other four backends (systemd-timer, kubernetes, aws-scheduler, vercel) implement the same `Adopter` interface in follow-up issues — track them under the `area/backend-` labels. + +Until those land, `cronix adopt --backend systemd-timer` returns a clear error pointing at the per-backend tracking issue. + +## Going deeper + +- [`cronix adopt` CLI reference](/cronix/cli/adopt/) *(pending)* +- [Production runbook §"the job stopped firing"](/cronix/operations/runbook/#the-job-stopped-firing) for what happens when a managed entry stops firing after adoption +- [D-026](https://github.com/awbx/cronix/blob/main/spec/DECISIONS.md) — ownership marker contract diff --git a/go/internal/backends/backend.go b/go/internal/backends/backend.go index 8730e25..f4ba541 100644 --- a/go/internal/backends/backend.go +++ b/go/internal/backends/backend.go @@ -106,3 +106,58 @@ type HistoryEntry struct { Source string Detail string } + +// Adopter is an optional Backend capability — adopting a pre-existing +// scheduler entry that already invokes cronix trigger (but lacks the +// D-026 ownership markers) into cronix's managed set, without +// disrupting the entry semantically. +// +// Backends that do not implement Adopter cannot be adopted into; the +// CLI surfaces a clear error in that case. Each v1 backend ships +// Adopter support in its own follow-up PR. +type Adopter interface { + // Adopt searches the backend for an entry that matches the manifest + // job and applies the cronix ownership markers without re-creating + // it. Returns AdoptResult describing what was found and what action + // was taken. + // + // When the candidate entry exists but diverges from the manifest + // (different schedule, different command line, etc.), Adopt MUST + // leave the entry untouched and return AdoptResult with + // Diverged=true plus a human-readable description of every + // divergence. The caller can then choose to Delete+Create instead. + // + // When opts.DryRun is true, Adopt MUST NOT modify the backend. + Adopt(ctx context.Context, app string, job manifest.NormalizedJob, opts AdoptOpts) (AdoptResult, error) +} + +// AdoptOpts narrows an Adopt call. +type AdoptOpts struct { + // DryRun means "report what would happen without modifying the backend." + DryRun bool +} + +// AdoptResult is the outcome of an Adopt call. +type AdoptResult struct { + // Found means the backend has at least one candidate entry that + // looks like it could be adopted (e.g. a crontab line invoking + // `cronix trigger .`). + Found bool + // Adopted means ownership markers were applied. False when DryRun + // or when Diverged. + Adopted bool + // AlreadyManaged means the entry was already a cronix-owned entry — + // adopt was a no-op. Not an error; common when re-running adopt. + AlreadyManaged bool + // Diverged means a candidate was found but it disagrees with the + // manifest in a way that would change semantics if cronix took over + // management. Divergences enumerates each difference. + Diverged bool + // Divergences is a human-readable list of differences (cron + // expression, command-line tail, etc.). Empty when Diverged=false. + Divergences []string + // Entries enumerates the entries that were (or would be) adopted. + // When DryRun=true these describe the candidate state; otherwise + // they describe the post-adopt state. + Entries []ManagedEntry +} diff --git a/go/internal/backends/crontab/adopt.go b/go/internal/backends/crontab/adopt.go new file mode 100644 index 0000000..c84ab0e --- /dev/null +++ b/go/internal/backends/crontab/adopt.go @@ -0,0 +1,254 @@ +package crontab + +import ( + "context" + "fmt" + "strings" + + "github.com/awbx/cronix/go/internal/backends" + "github.com/awbx/cronix/go/internal/manifest" + "github.com/awbx/cronix/go/internal/policy" +) + +// Adopt finds existing crontab lines that already invoke +// ` trigger .` and applies the +// cronix:owned marker so they come under management without +// re-creation. Implements backends.Adopter. +// +// Strict matching: a line is a candidate only if its command tail +// is exactly ` trigger .`. Lines invoking +// the trigger with extra arguments, a wrapper script, or a +// different triggerBin path are not adopted — they show up as +// divergences for the operator to reconcile manually. +// +// Multi-schedule jobs: every manifest schedule must have a +// matching candidate line. A partial match (some schedules +// without candidates) is reported as Diverged with explanations, +// not partially adopted — partial adoption would leave the +// crontab in a confusing state where some schedules are managed +// and others fall back to Create on the next apply. +func (b *Backend) Adopt(_ context.Context, app string, job manifest.NormalizedJob, opts backends.AdoptOpts) (backends.AdoptResult, error) { + if !appRe.MatchString(app) { + return backends.AdoptResult{}, fmt.Errorf("crontab: invalid app id %q", app) + } + if !jobRe.MatchString(job.Name) { + return backends.AdoptResult{}, fmt.Errorf("crontab: invalid job name %q", job.Name) + } + + _, lines, err := b.readFile() + if err != nil { + return backends.AdoptResult{}, err + } + + expectedTail := fmt.Sprintf("%s trigger %s.%s", b.triggerBin, app, job.Name) + + // Translate every manifest schedule once, fail-fast on anything the + // crontab backend cannot express. + type wantedSchedule struct { + idx int + manifest string + fiveField string + } + wanted := make([]wantedSchedule, 0, len(job.Schedules)) + for i, s := range job.Schedules { + ff, ok := translate(s) + if !ok { + return backends.AdoptResult{Diverged: true, Divergences: []string{ + fmt.Sprintf("schedules[%d] (%q) cannot be translated to 5-field crontab", i, s), + }}, nil + } + wanted = append(wanted, wantedSchedule{idx: i, manifest: s, fiveField: ff}) + } + + // Walk the file. For each candidate (a line whose command tail + // matches expectedTail), record its cron expression and the line + // index. Also detect AlreadyManaged early. + type candidate struct { + lineIndex int + fiveField string + } + var candidates []candidate + for i, line := range lines { + stripped := strings.TrimSpace(line) + if stripped == "" || strings.HasPrefix(stripped, "#") { + continue + } + // AlreadyManaged: the next line is the cronix owner marker. + // Skip — we don't double-count managed entries as candidates. + if i+1 < len(lines) && ownerLineRe.MatchString(lines[i+1]) { + continue + } + cron, tail, ok := splitScheduleAndCommand(stripped) + if !ok { + continue + } + if tail != expectedTail { + continue + } + candidates = append(candidates, candidate{lineIndex: i, fiveField: cron}) + } + + // Detect entries already cronix-managed. + managed, err := b.List(context.Background()) + if err != nil { + return backends.AdoptResult{}, err + } + managedForJob := 0 + for _, m := range managed { + if m.App == app && m.Job == job.Name { + managedForJob++ + } + } + if managedForJob == len(wanted) && len(candidates) == 0 { + // Everything is already adopted; nothing to do. + return backends.AdoptResult{AlreadyManaged: true}, nil + } + + // Match each wanted schedule to a candidate by 5-field cron. + // Each candidate can only satisfy one wanted schedule + // (handled by the consumed bitmap). + consumed := make([]bool, len(candidates)) + matched := make([]int, len(wanted)) // index into candidates; -1 = unmatched + for i := range matched { + matched[i] = -1 + } + for wIdx, w := range wanted { + for cIdx, c := range candidates { + if consumed[cIdx] { + continue + } + if c.fiveField == w.fiveField { + matched[wIdx] = cIdx + consumed[cIdx] = true + break + } + } + } + + // Build divergence report. + var divergences []string + unmatchedWanted := 0 + for wIdx, w := range wanted { + if matched[wIdx] < 0 { + unmatchedWanted++ + divergences = append(divergences, fmt.Sprintf( + "schedules[%d] (%q → %q): no candidate crontab line with this 5-field cron", + w.idx, w.manifest, w.fiveField, + )) + } + } + // Surface extras: candidates that exist on the host but don't + // match any manifest schedule. These are likely stale or + // hand-edited variants and would be left dangling on adopt; + // surface them so the operator can prune. + extraCandidates := 0 + for cIdx, c := range candidates { + if !consumed[cIdx] { + extraCandidates++ + divergences = append(divergences, fmt.Sprintf( + "line %d (cron %q) invokes %s but does not match any manifest schedule", + c.lineIndex+1, c.fiveField, expectedTail, + )) + } + } + + found := len(candidates) > 0 + if unmatchedWanted > 0 || extraCandidates > 0 { + return backends.AdoptResult{ + Found: found, + Diverged: true, + Divergences: divergences, + }, nil + } + + // All wanted schedules have matching candidates; build the + // post-adopt ManagedEntry list. + entries := make([]backends.ManagedEntry, 0, len(wanted)) + for wIdx, w := range wanted { + c := candidates[matched[wIdx]] + entries = append(entries, backends.ManagedEntry{ + App: app, + Job: job.Name, + Hash: policy.Hash(job, w.idx), + Index: w.idx, + Raw: lines[c.lineIndex], + }) + } + + if opts.DryRun { + return backends.AdoptResult{Found: true, Entries: entries}, nil + } + + // Apply: insert the owner marker line directly after each adopted + // schedule line. Because we're inserting (not removing), iterate + // in reverse so the lineIndex values stay valid as we mutate. + err = b.rewrite(func(in []string) []string { + out := make([]string, len(in)) + copy(out, in) + // Collect insertions (lineIndex, marker text) and apply in + // descending order so earlier indexes don't shift. + type insertion struct { + after int + marker string + } + ins := make([]insertion, 0, len(wanted)) + for wIdx, w := range wanted { + c := candidates[matched[wIdx]] + ins = append(ins, insertion{ + after: c.lineIndex, + marker: fmt.Sprintf("%s app=%s job=%s hash=%s idx=%d", ownerMarker, app, job.Name, policy.Hash(job, w.idx), w.idx), + }) + } + // Sort descending by `after`. + for i := 0; i < len(ins); i++ { + for j := i + 1; j < len(ins); j++ { + if ins[j].after > ins[i].after { + ins[i], ins[j] = ins[j], ins[i] + } + } + } + for _, ix := range ins { + out = append(out[:ix.after+1], append([]string{ix.marker}, out[ix.after+1:]...)...) + } + return out + }) + if err != nil { + return backends.AdoptResult{}, err + } + return backends.AdoptResult{Found: true, Adopted: true, Entries: entries}, nil +} + +// splitScheduleAndCommand splits a crontab line into "<5-field cron>" +// and "". Returns ok=false for lines that don't have at +// least 6 whitespace-separated fields. +// +// Crontab grammar varies (some files include a USER field, some have +// environment variable assignments), so this is intentionally minimal: +// only honors the 5-cron-fields + command form. Lines that don't fit +// are simply not adoption candidates. +func splitScheduleAndCommand(line string) (cron, command string, ok bool) { + fields := strings.Fields(line) + if len(fields) < 6 { + return "", "", false + } + // Crontab schedule fields can contain commas, ranges, steps — but + // never whitespace. The first 5 whitespace-separated fields are the + // schedule; everything after is the command. + cron = strings.Join(fields[:5], " ") + // Preserve internal whitespace in the command; can't just + // strings.Join(fields[5:]) because that would collapse spaces. + // Find the position of fields[5] in the original line. + idx := 0 + for fIdx, f := range fields { + if fIdx == 5 { + break + } + j := strings.Index(line[idx:], f) + if j < 0 { + return "", "", false + } + idx += j + len(f) + } + command = strings.TrimSpace(line[idx:]) + return cron, command, true +} diff --git a/go/internal/backends/crontab/adopt_test.go b/go/internal/backends/crontab/adopt_test.go new file mode 100644 index 0000000..07e006d --- /dev/null +++ b/go/internal/backends/crontab/adopt_test.go @@ -0,0 +1,194 @@ +package crontab + +import ( + "context" + "strings" + "testing" + + "github.com/awbx/cronix/go/internal/backends" +) + +func TestAdopt_SingleScheduleSuccess(t *testing.T) { + initial := "# user comment\n0 * * * * /usr/local/bin/cronix trigger billing.ping\n" + b := newBackend(t, initial) + + res, err := b.Adopt(context.Background(), "billing", sampleJob("ping", "@hourly"), backends.AdoptOpts{}) + if err != nil { + t.Fatalf("adopt: %v", err) + } + if !res.Adopted { + t.Fatalf("Adopted=false, want true. Result: %+v", res) + } + if res.Diverged { + t.Fatalf("Diverged=true, want false. Divergences: %v", res.Divergences) + } + if len(res.Entries) != 1 { + t.Fatalf("len(Entries) = %d, want 1", len(res.Entries)) + } + + got := read(t, b) + if !strings.Contains(got, "# user comment") { + t.Errorf("user comment lost:\n%s", got) + } + if !strings.Contains(got, "0 * * * * /usr/local/bin/cronix trigger billing.ping") { + t.Errorf("schedule line altered:\n%s", got) + } + if !strings.Contains(got, "# cronix:owned app=billing job=ping hash=") { + t.Errorf("ownership marker missing:\n%s", got) + } + // Critical: the original line was NOT removed and re-created. It + // stays in place, with the marker appended after it. + if strings.Count(got, "/usr/local/bin/cronix trigger billing.ping") != 1 { + t.Errorf("schedule line duplicated:\n%s", got) + } +} + +func TestAdopt_DryRunDoesNotModify(t *testing.T) { + initial := "0 * * * * /usr/local/bin/cronix trigger billing.ping\n" + b := newBackend(t, initial) + + res, err := b.Adopt(context.Background(), "billing", sampleJob("ping", "@hourly"), backends.AdoptOpts{DryRun: true}) + if err != nil { + t.Fatalf("adopt: %v", err) + } + if !res.Found { + t.Fatalf("Found=false, want true") + } + if res.Adopted { + t.Fatalf("Adopted=true under DryRun, want false") + } + got := read(t, b) + if got != initial { + t.Errorf("file modified under dry-run:\nbefore: %q\nafter: %q", initial, got) + } +} + +func TestAdopt_DivergentScheduleNoChange(t *testing.T) { + // Existing line fires every 5 minutes; manifest says hourly. Adopt + // must NOT modify (would silently change firing cadence). + initial := "*/5 * * * * /usr/local/bin/cronix trigger billing.ping\n" + b := newBackend(t, initial) + + res, err := b.Adopt(context.Background(), "billing", sampleJob("ping", "@hourly"), backends.AdoptOpts{}) + if err != nil { + t.Fatalf("adopt: %v", err) + } + if !res.Diverged { + t.Fatalf("Diverged=false, want true. Result: %+v", res) + } + if res.Adopted { + t.Fatalf("Adopted=true on divergent entry, want false") + } + got := read(t, b) + if got != initial { + t.Errorf("file modified despite divergence:\n%s", got) + } + // Both divergences should surface: wanted-no-match + extra-candidate. + joined := strings.Join(res.Divergences, "\n") + if !strings.Contains(joined, "no candidate crontab line with this 5-field cron") { + t.Errorf("missing wanted-unmatched divergence:\n%s", joined) + } + if !strings.Contains(joined, "does not match any manifest schedule") { + t.Errorf("missing extra-candidate divergence:\n%s", joined) + } +} + +func TestAdopt_AlreadyManagedIsNoOp(t *testing.T) { + b := newBackend(t, "") + job := sampleJob("ping", "@hourly") + if err := b.Create(context.Background(), "billing", job); err != nil { + t.Fatalf("create: %v", err) + } + before := read(t, b) + + res, err := b.Adopt(context.Background(), "billing", job, backends.AdoptOpts{}) + if err != nil { + t.Fatalf("adopt: %v", err) + } + if !res.AlreadyManaged { + t.Errorf("AlreadyManaged=false, want true. Result: %+v", res) + } + if res.Adopted { + t.Errorf("Adopted=true on already-managed, want false (it was a no-op)") + } + if read(t, b) != before { + t.Errorf("file changed on already-managed adopt") + } +} + +func TestAdopt_NoCandidateReportsNotFound(t *testing.T) { + // Crontab has lines but none invoke the expected trigger command. + initial := "0 * * * * /opt/some-other-script.sh\n0 0 * * * /usr/bin/echo hi\n" + b := newBackend(t, initial) + + res, err := b.Adopt(context.Background(), "billing", sampleJob("ping", "@hourly"), backends.AdoptOpts{}) + if err != nil { + t.Fatalf("adopt: %v", err) + } + if res.Found { + t.Errorf("Found=true, want false (no matching trigger lines)") + } + if !res.Diverged { + t.Errorf("expected Diverged=true (manifest schedule has no candidate)") + } +} + +func TestAdopt_MultiScheduleAllPresent(t *testing.T) { + initial := strings.Join([]string{ + "# day shift", + "0 9 * * * /usr/local/bin/cronix trigger billing.ping", + "# night shift", + "0 21 * * * /usr/local/bin/cronix trigger billing.ping", + "", + }, "\n") + b := newBackend(t, initial) + job := sampleJob("ping", "0 9 * * *", "0 21 * * *") + + res, err := b.Adopt(context.Background(), "billing", job, backends.AdoptOpts{}) + if err != nil { + t.Fatalf("adopt: %v", err) + } + if !res.Adopted || res.Diverged { + t.Fatalf("expected clean adopt, got: %+v", res) + } + if len(res.Entries) != 2 { + t.Errorf("len(Entries) = %d, want 2", len(res.Entries)) + } + got := read(t, b) + if strings.Count(got, "# cronix:owned app=billing job=ping") != 2 { + t.Errorf("expected 2 owner markers, got:\n%s", got) + } + // Both schedule lines preserved, neither moved. + if !strings.Contains(got, "0 9 * * * /usr/local/bin/cronix trigger billing.ping") { + t.Errorf("day-shift line missing:\n%s", got) + } + if !strings.Contains(got, "0 21 * * * /usr/local/bin/cronix trigger billing.ping") { + t.Errorf("night-shift line missing:\n%s", got) + } +} + +func TestAdopt_PartialMultiScheduleIsDivergent(t *testing.T) { + // Manifest wants two schedules but only one exists on host. + initial := "0 9 * * * /usr/local/bin/cronix trigger billing.ping\n" + b := newBackend(t, initial) + job := sampleJob("ping", "0 9 * * *", "0 21 * * *") + + res, err := b.Adopt(context.Background(), "billing", job, backends.AdoptOpts{}) + if err != nil { + t.Fatalf("adopt: %v", err) + } + if !res.Diverged { + t.Errorf("expected Diverged=true (1 of 2 schedules missing)") + } + if res.Adopted { + t.Errorf("expected Adopted=false (partial match)") + } + if read(t, b) != initial { + t.Errorf("file modified on partial adopt") + } +} + +func TestAdopt_AdoptInterfaceCompliance(t *testing.T) { + // Compile-time check that *Backend satisfies backends.Adopter. + var _ backends.Adopter = (*Backend)(nil) +} diff --git a/go/internal/cli/commands/adopt.go b/go/internal/cli/commands/adopt.go new file mode 100644 index 0000000..c223a84 --- /dev/null +++ b/go/internal/cli/commands/adopt.go @@ -0,0 +1,180 @@ +package commands + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/awbx/cronix/go/internal/backends" + "github.com/awbx/cronix/go/internal/manifest" +) + +// newAdoptCmd builds `cronix adopt .` — claims an existing +// scheduler entry that already invokes `cronix trigger` so it comes +// under cronix management without being re-created. Implements +// issue #11 (the crontab side; other backends pending Adopter +// implementations). +func newAdoptCmd() *cobra.Command { + var ( + opts backendOpts + manifestSrc string + dryRun bool + output string + ) + cmd := &cobra.Command{ + Use: "adopt .", + Short: "Take ownership of an existing scheduler entry without re-creating it", + Long: `adopt finds a backend entry that already invokes ` + "`cronix trigger .`" + ` and +applies the cronix ownership markers so the entry comes under management +without being re-created. The original line/unit/CronJob/schedule is +preserved byte-for-byte; only the ownership annotation is added. + +When the candidate entry diverges from the manifest in a way that would +change semantics (different schedule, different command-line tail, etc.), +adopt refuses to modify it and prints the divergences. Re-run with +` + "`cronix apply`" + ` after reconciling the manifest if you want the entry +brought into agreement. + +Migration story: see docs-site §Operations → Migrating from hand-edited +crontab. + +Supported backends (v1.0.0-rc.1): crontab. systemd-timer, kubernetes, +aws-scheduler, and vercel adopt support land as follow-up issues. + +Sources: + ./manifest.json + /etc/manifest.json + file://path + https://app/.well-known/cron-manifest`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + parts := strings.SplitN(args[0], ".", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("argument must be `.`, got %q", args[0]) + } + app, jobName := parts[0], parts[1] + + if manifestSrc == "" { + return fmt.Errorf("--manifest is required") + } + + normalized, err := loadAndNormalize(cmd.Context(), manifestSrc, opts.secretRefs) + if err != nil { + return fmt.Errorf("load manifest: %w", err) + } + if normalized.App != app { + return fmt.Errorf("manifest declares app %q, command argument is for %q", normalized.App, app) + } + + job, ok := findJob(normalized, jobName) + if !ok { + return fmt.Errorf("job %q not found in manifest", jobName) + } + + be, err := buildBackend(opts) + if err != nil { + return fmt.Errorf("build backend: %w", err) + } + + adopter, ok := be.(backends.Adopter) + if !ok { + return fmt.Errorf("backend %q does not yet support adopt — track the per-backend implementation issue under area/backend-%s", be.Name(), be.Name()) + } + + res, err := adopter.Adopt(cmd.Context(), app, *job, backends.AdoptOpts{DryRun: dryRun}) + if err != nil { + return fmt.Errorf("adopt: %w", err) + } + return printAdoptResult(cmd, output, app, jobName, dryRun, res) + }, + } + cmd.Flags().StringVar(&manifestSrc, "manifest", "", "manifest source (URL or path); required") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "report what would be adopted without modifying the backend") + cmd.Flags().StringVarP(&output, "output", "o", "table", "output format: table|json") + bindBackendFlags(cmd, &opts) + return cmd +} + +// findJob returns the first NormalizedJob whose Name matches. +func findJob(m *manifest.NormalizedManifest, name string) (*manifest.NormalizedJob, bool) { + for i := range m.Jobs { + if m.Jobs[i].Name == name { + return &m.Jobs[i], true + } + } + return nil, false +} + +type adoptReport struct { + App string `json:"app"` + Job string `json:"job"` + DryRun bool `json:"dry_run"` + Found bool `json:"found"` + Adopted bool `json:"adopted"` + AlreadyManaged bool `json:"already_managed"` + Diverged bool `json:"diverged"` + Divergences []string `json:"divergences,omitempty"` + Entries []backends.ManagedEntry `json:"entries,omitempty"` +} + +func printAdoptResult(cmd *cobra.Command, output, app, job string, dryRun bool, r backends.AdoptResult) error { + rep := adoptReport{ + App: app, + Job: job, + DryRun: dryRun, + Found: r.Found, + Adopted: r.Adopted, + AlreadyManaged: r.AlreadyManaged, + Diverged: r.Diverged, + Divergences: r.Divergences, + Entries: r.Entries, + } + if output == "json" { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(rep) + } + out := cmd.OutOrStdout() + switch { + case r.AlreadyManaged: + fmt.Fprintf(out, "ALREADY-MANAGED %s.%s (no action taken)\n", app, job) + case r.Diverged: + fmt.Fprintf(out, "DIVERGED %s.%s (no action taken)\n", app, job) + for _, d := range r.Divergences { + fmt.Fprintf(out, " ! %s\n", d) + } + fmt.Fprintln(out, "\nResolve by editing the manifest or the backend so they agree, then re-run `cronix adopt`. Or run `cronix apply` to overwrite the backend entry with the manifest's form.") + case r.Adopted: + fmt.Fprintf(out, "ADOPTED %s.%s (%d entr%s now under management)\n", app, job, len(r.Entries), pluralY(len(r.Entries))) + for _, e := range r.Entries { + fmt.Fprintf(out, " + idx=%d hash=%s\n", e.Index, e.Hash) + } + case r.Found && dryRun: + fmt.Fprintf(out, "WOULD-ADOPT %s.%s (%d entr%s — dry-run, no changes)\n", app, job, len(r.Entries), pluralY(len(r.Entries))) + for _, e := range r.Entries { + fmt.Fprintf(out, " + idx=%d hash=%s\n", e.Index, e.Hash) + } + default: + fmt.Fprintf(out, "NOT-FOUND %s.%s (no candidate entry on backend)\n", app, job) + } + // Non-zero exit when adopt could not complete cleanly; this is how + // CI consumers detect "needs operator attention". Already-managed and + // successful adopt are exit 0. Dry-run that found candidates is also + // exit 0 — the user asked for a preview, not a commit. + if r.Diverged { + return exitErr{code: 6, msg: "adopt: candidate diverges from manifest"} + } + if !r.Found && !r.AlreadyManaged { + return exitErr{code: 7, msg: "adopt: no candidate entry found"} + } + return nil +} + +func pluralY(n int) string { + if n == 1 { + return "y" + } + return "ies" +} diff --git a/go/internal/cli/commands/root.go b/go/internal/cli/commands/root.go index cb913b0..fa8e562 100644 --- a/go/internal/cli/commands/root.go +++ b/go/internal/cli/commands/root.go @@ -31,6 +31,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newShowCmd()) cmd.AddCommand(newHistoryCmd()) cmd.AddCommand(newInitCmd()) + cmd.AddCommand(newAdoptCmd()) cmd.AddCommand(newCompletionCmd(cmd)) return cmd } From 3b9ee6fb2d175c79bf0d1f5650509dc20b1c00c7 Mon Sep 17 00:00:00 2001 From: Abdelhadi Sabani Date: Fri, 22 May 2026 18:29:23 +0100 Subject: [PATCH 2/2] fixup: gofmt adopt.go column alignment The var block in newAdoptCmd had inconsistent column alignment; gofmt -w fixed it. Caught by CI's gofmt -l check, which is the only thing that flagged in the Go job. Signed-off-by: Abdelhadi Sabani --- go/internal/cli/commands/adopt.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/go/internal/cli/commands/adopt.go b/go/internal/cli/commands/adopt.go index c223a84..f3f84c0 100644 --- a/go/internal/cli/commands/adopt.go +++ b/go/internal/cli/commands/adopt.go @@ -18,10 +18,10 @@ import ( // implementations). func newAdoptCmd() *cobra.Command { var ( - opts backendOpts + opts backendOpts manifestSrc string - dryRun bool - output string + dryRun bool + output string ) cmd := &cobra.Command{ Use: "adopt .",